diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 69e33b978d..9d271bea8d 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,28 +3,39 @@ "isRoot": true, "tools": { "jetbrains.resharper.globaltools": { - "version": "2021.3.4", + "version": "2024.3.6", "commands": [ "jb" - ] + ], + "rollForward": false }, "regitlint": { - "version": "6.0.8", + "version": "6.3.13", "commands": [ "regitlint" - ] - }, - "codecov.tool": { - "version": "1.13.0", - "commands": [ - "codecov" - ] + ], + "rollForward": false }, "dotnet-reportgenerator-globaltool": { - "version": "5.1.3", + "version": "5.4.7", "commands": [ "reportgenerator" - ] + ], + "rollForward": false + }, + "docfx": { + "version": "2.78.2", + "commands": [ + "docfx" + ], + "rollForward": false + }, + "microsoft.openapi.kiota": { + "version": "1.27.0", + "commands": [ + "kiota" + ], + "rollForward": false } } -} +} \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index b6d9a8990c..2e5c1061b9 100644 --- a/.editorconfig +++ b/.editorconfig @@ -4,64 +4,125 @@ root = true [*] indent_style = space indent_size = 4 +tab-width = 4 charset = utf-8 trim_trailing_whitespace = true -end_of_line = lf insert_final_newline = true -[*.{csproj,json}] +[*.{config,csproj,css,js,json,props,targets,xml,ruleset,xsd,xslt,html,yml,yaml}] indent_size = 2 +tab-width = 2 +max_line_length = 160 -[*.{cs}] -#### .NET Coding Conventions #### +[*.{cs,cshtml,ascx,aspx}] -# Organize usings +#### C#/.NET Coding Conventions #### + +# Default severity for IDE* analyzers with category 'Style' +# Note: specific rules below use severity silent, because Resharper code cleanup auto-fixes them. +dotnet_analyzer_diagnostic.category-Style.severity = warning + +# 'using' directive preferences dotnet_sort_system_directives_first = true +csharp_using_directive_placement = outside_namespace:silent +# IDE0005: Remove unnecessary import +dotnet_diagnostic.IDE0005.severity = silent + +# Namespace declarations +csharp_style_namespace_declarations = file_scoped:silent +# IDE0160: Use block-scoped namespace +dotnet_diagnostic.IDE0160.severity = silent +# IDE0161: Use file-scoped namespace +dotnet_diagnostic.IDE0161.severity = silent # this. preferences -dotnet_style_qualification_for_field = false:suggestion -dotnet_style_qualification_for_property = false:suggestion -dotnet_style_qualification_for_method = false:suggestion -dotnet_style_qualification_for_event = false:suggestion +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_property = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_event = false:silent +# IDE0003: Remove this or Me qualification +dotnet_diagnostic.IDE0003.severity = silent +# IDE0009: Add this or Me qualification +dotnet_diagnostic.IDE0009.severity = silent # Language keywords vs BCL types preferences -dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion -dotnet_style_predefined_type_for_member_access = true:suggestion +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent +# IDE0049: Use language keywords instead of framework type names for type references +dotnet_diagnostic.IDE0049.severity = silent # Modifier preferences -dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion -csharp_style_pattern_local_over_anonymous_function = false:silent +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent +# IDE0040: Add accessibility modifiers +dotnet_diagnostic.IDE0040.severity = silent +csharp_preferred_modifier_order = public, private, protected, internal, new, static, abstract, virtual, sealed, readonly, override, extern, unsafe, volatile, async:silent +# IDE0036: Order modifiers +dotnet_diagnostic.IDE0036.severity = silent # Expression-level preferences dotnet_style_operator_placement_when_wrapping = end_of_line -dotnet_style_prefer_auto_properties = true:suggestion -dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion -dotnet_style_prefer_conditional_expression_over_return = true:suggestion +dotnet_style_prefer_auto_properties = true:silent +# IDE0032: Use auto property +dotnet_diagnostic.IDE0032.severity = silent +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +# IDE0045: Use conditional expression for assignment +dotnet_diagnostic.IDE0045.severity = silent +dotnet_style_prefer_conditional_expression_over_return = true:silent +# IDE0046: Use conditional expression for return +dotnet_diagnostic.IDE0046.severity = silent +csharp_style_unused_value_expression_statement_preference = discard_variable:silent +# IDE0058: Remove unused expression value +dotnet_diagnostic.IDE0058.severity = silent + +# Collection expression preferences (note: partially turned off in Directory.Build.props) +dotnet_style_prefer_collection_expression = when_types_exactly_match # Parameter preferences -dotnet_code_quality_unused_parameters = non_public:suggestion +dotnet_code_quality_unused_parameters = non_public + +# Local functions vs lambdas +csharp_style_prefer_local_over_anonymous_function = false:silent +# IDE0039: Use local function instead of lambda +dotnet_diagnostic.IDE0039.severity = silent # Expression-bodied members -csharp_style_expression_bodied_accessors = true:suggestion -csharp_style_expression_bodied_constructors = false:suggestion -csharp_style_expression_bodied_indexers = true:suggestion -csharp_style_expression_bodied_lambdas = true:suggestion -csharp_style_expression_bodied_local_functions = false:suggestion -csharp_style_expression_bodied_methods = false:suggestion -csharp_style_expression_bodied_operators = false:suggestion -csharp_style_expression_bodied_properties = true:suggestion +csharp_style_expression_bodied_accessors = true:silent +# IDE0027: Use expression body for accessors +dotnet_diagnostic.IDE0027.severity = silent +csharp_style_expression_bodied_constructors = false:silent +# IDE0021: Use expression body for constructors +dotnet_diagnostic.IDE0021.severity = silent +csharp_style_expression_bodied_indexers = true:silent +# IDE0026: Use expression body for indexers +dotnet_diagnostic.IDE0026.severity = silent +csharp_style_expression_bodied_lambdas = true:silent +# IDE0053: Use expression body for lambdas +dotnet_diagnostic.IDE0053.severity = silent +csharp_style_expression_bodied_local_functions = false:silent +# IDE0061: Use expression body for local functions +dotnet_diagnostic.IDE0061.severity = silent +csharp_style_expression_bodied_methods = false:silent +# IDE0022: Use expression body for methods +dotnet_diagnostic.IDE0022.severity = silent +csharp_style_expression_bodied_operators = false:silent +# IDE0023: Use expression body for conversion operators +dotnet_diagnostic.IDE0023.severity = silent +# IDE0024: Use expression body for operators +dotnet_diagnostic.IDE0024.severity = silent +csharp_style_expression_bodied_properties = true:silent +# IDE0025: Use expression body for properties +dotnet_diagnostic.IDE0025.severity = silent + +# Member preferences (these analyzers are unreliable) +# IDE0051: Remove unused private member +dotnet_diagnostic.IDE0051.severity = silent +# IDE0052: Remove unread private member +dotnet_diagnostic.IDE0052.severity = silent # Code-block preferences -csharp_prefer_braces = true:suggestion - -# Expression-level preferences -csharp_style_unused_value_expression_statement_preference = discard_variable:suggestion - -# 'using' directive preferences -csharp_using_directive_placement = outside_namespace:suggestion - - -#### C# Formatting Rules #### +csharp_prefer_braces = true:silent +# IDE0011: Add braces +dotnet_diagnostic.IDE0011.severity = silent # Indentation preferences csharp_indent_case_contents_when_block = false @@ -69,23 +130,60 @@ csharp_indent_case_contents_when_block = false # Wrapping preferences csharp_preserve_single_line_statements = false - -#### Naming styles #### +# 'var' usage preferences +csharp_style_var_for_built_in_types = false:silent +csharp_style_var_when_type_is_apparent = true:silent +csharp_style_var_elsewhere = false:silent +# IDE0007: Use var instead of explicit type +dotnet_diagnostic.IDE0007.severity = silent +# IDE0008: Use explicit type instead of var +dotnet_diagnostic.IDE0008.severity = silent + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = never_if_unnecessary:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_relational_binary_operators = never_if_unnecessary:silent +# IDE0047: Remove unnecessary parentheses +dotnet_diagnostic.IDE0047.severity = silent +# IDE0048: Add parentheses for clarity +dotnet_diagnostic.IDE0048.severity = silent + +# Switch preferences +# IDE0010: Add missing cases to switch statement +dotnet_diagnostic.IDE0010.severity = silent +# IDE0072: Add missing cases to switch expression +dotnet_diagnostic.IDE0072.severity = silent + +# Null check preferences +# IDE0029: Null check can be simplified +dotnet_diagnostic.IDE0029.severity = silent +# IDE0030: Null check can be simplified +dotnet_diagnostic.IDE0030.severity = silent +# IDE0270: Null check can be simplified +dotnet_diagnostic.IDE0270.severity = silent + +# JSON002: Probable JSON string detected +dotnet_diagnostic.JSON002.severity = silent + +# CA1062: Validate arguments of public methods +dotnet_code_quality.CA1062.excluded_symbol_names = Accept|DefaultVisit|Visit*|Apply* + +#### .NET Naming Style #### dotnet_diagnostic.IDE1006.severity = warning # Naming rules -dotnet_naming_rule.private_const_fields_should_be_pascal_case.symbols = private_const_fields -dotnet_naming_rule.private_const_fields_should_be_pascal_case.style = pascal_case -dotnet_naming_rule.private_const_fields_should_be_pascal_case.severity = warning +dotnet_naming_rule.const_fields_should_be_pascal_case.symbols = const_fields +dotnet_naming_rule.const_fields_should_be_pascal_case.style = pascal_case +dotnet_naming_rule.const_fields_should_be_pascal_case.severity = warning dotnet_naming_rule.private_static_readonly_fields_should_be_pascal_case.symbols = private_static_readonly_fields dotnet_naming_rule.private_static_readonly_fields_should_be_pascal_case.style = pascal_case dotnet_naming_rule.private_static_readonly_fields_should_be_pascal_case.severity = warning -dotnet_naming_rule.private_static_or_readonly_fields_should_start_with_underscore.symbols = private_static_or_readonly_fields -dotnet_naming_rule.private_static_or_readonly_fields_should_start_with_underscore.style = camel_case_prefix_with_underscore -dotnet_naming_rule.private_static_or_readonly_fields_should_start_with_underscore.severity = warning +dotnet_naming_rule.private_fields_should_start_with_underscore.symbols = private_fields +dotnet_naming_rule.private_fields_should_start_with_underscore.style = camel_case_prefix_with_underscore +dotnet_naming_rule.private_fields_should_start_with_underscore.severity = warning dotnet_naming_rule.locals_and_parameters_should_be_camel_case.symbols = locals_and_parameters dotnet_naming_rule.locals_and_parameters_should_be_camel_case.style = camel_case @@ -96,25 +194,24 @@ dotnet_naming_rule.types_and_members_should_be_pascal_case.style = pascal_case dotnet_naming_rule.types_and_members_should_be_pascal_case.severity = warning # Symbol specifications -dotnet_naming_symbols.private_const_fields.applicable_kinds = field -dotnet_naming_symbols.private_const_fields.applicable_accessibilities = private -dotnet_naming_symbols.private_const_fields.required_modifiers = const +dotnet_naming_symbols.const_fields.applicable_kinds = field +dotnet_naming_symbols.const_fields.applicable_accessibilities = * +dotnet_naming_symbols.const_fields.required_modifiers = const dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private -dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = static,readonly +dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = static, readonly -dotnet_naming_symbols.private_static_or_readonly_fields.applicable_kinds = field -dotnet_naming_symbols.private_static_or_readonly_fields.applicable_accessibilities = private -dotnet_naming_symbols.private_static_or_readonly_fields.required_modifiers = static readonly +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private -dotnet_naming_symbols.locals_and_parameters.applicable_kinds = local,parameter +dotnet_naming_symbols.locals_and_parameters.applicable_kinds = local, parameter dotnet_naming_symbols.locals_and_parameters.applicable_accessibilities = * dotnet_naming_symbols.types_and_members.applicable_kinds = * dotnet_naming_symbols.types_and_members.applicable_accessibilities = * -# Naming styles +# Style specifications dotnet_naming_style.pascal_case.capitalization = pascal_case dotnet_naming_style.camel_case_prefix_with_underscore.required_prefix = _ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..0c78db34f3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# When running OpenAPI tests, these committed files are downloaded and written to disk (so we'll know when something changes). +# On Windows, these text files are auto-converted to crlf on git fetch, while the written downloaded files use lf line endings. +# Therefore, running the tests on Windows creates local changes. Staging them auto-converts back to crlf, which undoes the changes. +# To avoid this annoyance, the next line opts out of the auto-conversion and forces line endings to lf. +**/GeneratedSwagger/**/*.json text eol=lf diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 150c8b45df..14b5da9852 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,8 +1,6 @@ # I don't want to read this whole thing I just have a question!!! -> Note: Please don't file an issue to ask a question. - -You'll get faster results by using our official [Gitter channel](https://gitter.im/json-api-dotnet-core/Lobby) or [StackOverflow](https://stackoverflow.com/search?q=jsonapidotnetcore) where the community chimes in with helpful advice if you have questions. +> You can file an issue to ask a question, but you'll get faster results by using our official [Gitter channel](https://gitter.im/json-api-dotnet-core/Lobby) or [StackOverflow](https://stackoverflow.com/search?q=jsonapidotnetcore) where the community chimes in with helpful advice if you have questions. # How can I contribute? @@ -24,7 +22,7 @@ Bugs are tracked as [GitHub issues](https://github.com/json-api-dotnet/JsonApiDo Explain the problem and include additional details to help maintainers reproduce the problem: - **Use a clear and descriptive title** for the issue to identify the problem. -- **Describe the exact steps which reproduce the problem** in as many details as possible. When listing steps, don't just say what you did, but explain how you did it. +- **Describe the exact steps which reproduce the problem** in as many details as possible. When listing steps, don't just say what you did, but explain how you did it. - **Provide specific examples to demonstrate the steps.** Include links to files or GitHub projects, or copy/pasteable snippets, which you use in those examples. If you're providing snippets in the issue, use [Markdown code blocks](https://docs.github.com/en/github/writing-on-github/creating-and-highlighting-code-blocks). - **Describe the behavior you observed after following the steps** and point out what exactly is the problem with that behavior. Explain which behavior you expected to see instead and why. - **If you're reporting a crash**, include the full exception stack trace. @@ -41,7 +39,7 @@ When you are creating an enhancement suggestion, please include as many details - **Use a clear and descriptive title** for the issue to identify the suggestion. - **Provide a step-by-step description of the suggested enhancement** in as many details as possible. -- **Provide specific examples to demonstrate the usage.** Include copy/pasteable snippets which you use in those examples, as [Markdown code blocks](https://docs.github.com/en/github/writing-on-github/creating-and-highlighting-code-blocks). +- **Provide specific examples to demonstrate the usage.** Include copy/pasteable snippets which you use in those examples as [Markdown code blocks](https://docs.github.com/en/github/writing-on-github/creating-and-highlighting-code-blocks). - **Describe the current behavior and explain which behavior you expected to see instead** and why. - **Explain why this enhancement would be useful** to most users and isn't something that can or should be implemented in your API project directly. - **Verify that your enhancement does not conflict** with the [JSON:API specification](https://jsonapi.org/). @@ -58,10 +56,10 @@ Please follow these steps to have your contribution considered by the maintainer - Follow all instructions in the template. Don't forget to add tests and update documentation. - After you submit your pull request, verify that all status checks are passing. In release builds, all compiler warnings are treated as errors, so you should address them before push. -We use [CSharpGuidelines](https://csharpcodingguidelines.com/) as our coding standard (with a few minor exceptions). Coding style is validated during PR build, where we inject an extra settings layer that promotes various suggestions to warning level. This ensures a high-quality codebase without interfering too much when editing code. +We use [CSharpGuidelines](https://csharpcodingguidelines.com/) as our coding standard. Coding style is validated during PR build, where we inject an extra settings layer that promotes various IDE suggestions to warning level. This ensures a high-quality codebase without interfering too much while editing code. You can run the following [PowerShell scripts](https://github.com/PowerShell/PowerShell/releases) locally: -- `pwsh inspectcode.ps1`: Scans the code for style violations and opens the result in your web browser. -- `pwsh cleanupcode.ps1`: Reformats the entire codebase to match with our configured style. +- `pwsh ./inspectcode.ps1`: Scans the code for style violations and opens the result in your web browser. +- `pwsh ./cleanupcode.ps1 [branch-name-or-commit-hash]`: Reformats the codebase to match with our configured style, optionally only changed files since the specified branch (usually master). Code inspection violations can be addressed in several ways, depending on the situation: - Types that are reported to be never instantiated (because the IoC container creates them dynamically) should be decorated with `[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]`. @@ -88,13 +86,39 @@ public sealed class AppDbContext : DbContext } ``` +### Pull request workflow + +Please follow the steps and guidelines below for a smooth experience. + +Authors: +- When opening a new pull request, create it in **Draft** mode. +- After you've completed the work *and* all checks are green, click the **Ready for review** button. + - If you have permissions to do so, ask a team member for review. +- Once the review has started, don't force-push anymore. +- When you've addressed feedback from a conversation, mark it with a thumbs-up or add a some text. +- Don't close a conversation you didn't start. The creator closes it after verifying the concern has been addressed. +- Apply suggestions in a batch, instead of individual commits (to minimize email notifications). +- Re-request review when you're ready for another round. +- If you want to clean up your commits before merge, let the reviewer know in time. This is optional. + +Reviewers: +- If you're unable to review within a few days, let the author know what to expect. +- Use **Start a review** instead of **Add single comment** (to minimize email notifications). +- Consider to use suggestions (the ± button). +- Don't close a conversation you didn't start. Close the ones you opened after verifying the concern has been addressed. +- Once approved, use a merge commit only if all commits are clean. Otherwise, squash them into a single commit. + A commit is considered clean when: + - It is properly documented and covers all aspects of an isolated change (code, style, tests, docs). + - Checking out the commit results in a green build. + - Having this commit show up in the history is helpful (and can potentially be reverted). + ## Creating a release (for maintainers) - Verify documentation is up-to-date -- Bump the package version in Directory.Build.props +- Bump the package version in `Directory.Build.props` - Create a GitHub release -- Update https://github.com/json-api-dotnet/JsonApiDotNetCore.MongoDb to consume the new version and release -- Create a new branch in https://github.com/json-api-dotnet/MigrationGuide and update README.md in master +- Update [JsonApiDotNetCore.MongoDb](https://github.com/json-api-dotnet/JsonApiDotNetCore.MongoDb) to consume the new version and release +- Create a new branch in [MigrationGuide](https://github.com/json-api-dotnet/MigrationGuide) and update README.md in master, if major version change ## Backporting and hotfixes (for maintainers) @@ -103,7 +127,7 @@ public sealed class AppDbContext : DbContext git checkout tags/v2.5.1 -b release/2.5.2 ``` - Cherrypick the merge commit: `git cherry-pick {git commit SHA}` -- Bump the package version in Directory.Build.props +- Bump the package version in `Directory.Build.props` - Make any other compatibility, documentation, or tooling related changes - Push the branch to origin and verify the build - Once the build is verified, create a GitHub release, tagging the release branch 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/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index e0c2388a38..02e871bd7c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,19 +1,19 @@ --- name: Bug report -about: Create a report to help us improve +about: Create a report to help us improve. title: '' -labels: '' +labels: 'bug' assignees: '' --- -_Please read our [Contributing Guides](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/.github/CONTRIBUTING.md) before submitting a bug._ + #### DESCRIPTION -_A clear and concise description of what the bug is._ + #### STEPS TO REPRODUCE -_Consider to include your code here, such as models, DbContext, controllers, resource services, repositories, resource definitions etc. Please also include the request URL with body (if applicable) and the full exception stack trace (set `options.IncludeExceptionStackTraceInErrors` to `true`) in case of errors._ It may also be helpful to include the produced SQL, which can be made visible in logs by adding this to appsettings.json: + 1. 2. 3. #### EXPECTED BEHAVIOR -_A clear and concise description of what you expected to happen._ + #### ACTUAL BEHAVIOR -_A clear and concise description of what happens instead._ + #### VERSIONS USED - JsonApiDotNetCore version: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..8bf0a9112f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,14 @@ +blank_issues_enabled: true +contact_links: +- name: Documentation + url: https://www.jsonapi.net/usage/resources/index.html + about: Read our comprehensive documentation. +- name: Sponsor JsonApiDotNetCore + url: https://github.com/sponsors/json-api-dotnet + about: Help the continued development. +- name: Ask on Gitter + url: https://gitter.im/json-api-dotnet-core/Lobby + about: Get in touch with the whole community. +- name: Ask on Stack Overflow + url: https://stackoverflow.com/questions/tagged/json-api + about: The best place for asking general-purpose questions. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index f7b806fe50..019f7a9767 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,22 +1,22 @@ --- name: Feature request -about: Suggest an idea for this project +about: Suggest an idea for this project. title: '' -labels: '' +labels: 'enhancement' assignees: '' --- -_Please read our [Contributing Guides](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/.github/CONTRIBUTING.md) before suggesting an idea._ + **Is your feature request related to a problem? Please describe.** -_A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]_ + **Describe the solution you'd like** -_A clear and concise description of what you want to happen._ + **Describe alternatives you've considered** -_A clear and concise description of any alternative solutions or features you've considered._ + **Additional context** -_Add any other context or screenshots about the feature request here._ + diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 0000000000..7bf89d4e46 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,49 @@ +--- +name: Question +about: Ask a question. +title: '' +labels: 'question' +assignees: '' + +--- + + + +#### SUMMARY + + +#### DETAILS + + + +#### STEPS TO REPRODUCE + + +1. +2. +3. + +#### VERSIONS USED +- JsonApiDotNetCore version: +- ASP.NET Core version: +- Entity Framework Core version: +- Database provider: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 3e23a87e27..1a1c618dd7 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,6 +4,6 @@ Closes #{ISSUE_NUMBER} #### QUALITY CHECKLIST - [ ] Changes implemented in code -- [ ] Complies with our [contributing guidelines](./.github/CONTRIBUTING.md) +- [ ] Complies with our [contributing guidelines](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/.github/CONTRIBUTING.md) - [ ] Adapted tests - [ ] Documentation updated diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..beb6e779ed --- /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..bc38da9f39 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,295 @@ +# 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_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + +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: Tune GitHub-hosted runner network + uses: smorimoto/tune-github-hosted-runner-network@v1 + - name: Setup PostgreSQL + uses: ikalnytskyi/action-setup-postgres@v6 + with: + username: postgres + password: postgres + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.* + 9.0.* + - 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.Length -eq 1 ? '' : $segments[1..$($segments.Length - 1)] -join '-' + + [xml]$xml = Get-Content Directory.Build.props + $configuredVersionPrefix = $xml.Project.PropertyGroup.VersionPrefix | Select-Object -First 1 + + 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 VersionPrefix 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: | + dotnet build --no-restore --configuration Release /p:VersionSuffix=$env:PACKAGE_VERSION_SUFFIX + - name: Test + env: + # Override log levels, to reduce logging output when running tests in ci-build. + Logging__LogLevel__Microsoft.Hosting.Lifetime: 'None' + Logging__LogLevel__Microsoft.AspNetCore.Hosting.Diagnostics: 'None' + Logging__LogLevel__Microsoft.Extensions.Hosting.Internal.Host: 'None' + Logging__LogLevel__Microsoft.EntityFrameworkCore.Database.Command: 'None' + Logging__LogLevel__JsonApiDotNetCore: 'None' + run: | + dotnet test --no-build --configuration Release --collect:"XPlat Code Coverage" --logger "GitHubActions;summary.includeSkippedTests=true" + - name: Upload coverage to codecov.io + if: matrix.os == 'ubuntu-latest' + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + uses: codecov/codecov-action@v5 + with: + fail_ci_if_error: true + verbose: true + - name: Generate packages + shell: pwsh + run: | + dotnet pack --no-build --configuration Release --output $env:GITHUB_WORKSPACE/artifacts/packages /p:VersionSuffix=$env:PACKAGE_VERSION_SUFFIX + - name: Upload packages to artifacts + if: matrix.os == 'ubuntu-latest' + uses: actions/upload-artifact@v4 + 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 --warningsAsErrors true + 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@v4 + 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: Tune GitHub-hosted runner network + uses: smorimoto/tune-github-hosted-runner-network@v1 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.* + 9.0.* + - 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 --dotnetcoresdk=$(dotnet --version) --output="$inspectCodeOutputPath" --format="xml" --profile=WarningSeverities.DotSettings --properties:Configuration=Release --properties:ContinuousIntegrationBuild=false --properties:RunAnalyzers=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: Tune GitHub-hosted runner network + uses: smorimoto/tune-github-hosted-runner-network@v1 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.* + 9.0.* + - 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 --dotnetcoresdk=$(dotnet --version) --jb-profile="JADNC Full Cleanup" --jb --properties:Configuration=Release --jb --properties:RunAnalyzers=false --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 --dotnetcoresdk=$(dotnet --version) --jb-profile="JADNC Full Cleanup" --jb --properties:Configuration=Release --jb --properties:RunAnalyzers=false --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: Tune GitHub-hosted runner network + uses: smorimoto/tune-github-hosted-runner-network@v1 + - name: Download artifacts + uses: actions/download-artifact@v4 + - 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 to feedz.io + if: github.event_name == 'push' || github.event_name == 'release' + env: + FEEDZ_IO_API_KEY: ${{ secrets.FEEDZ_IO_API_KEY }} + shell: pwsh + run: | + dotnet nuget add source --name 'feedz-io' 'https://f.feedz.io/json-api-dotnet/jsonapidotnetcore/nuget/index.json' + dotnet nuget push "$env:GITHUB_WORKSPACE/packages/*.nupkg" --api-key "$env:FEEDZ_IO_API_KEY" --source 'feedz-io' + - name: Publish documentation + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + uses: peaceiris/actions-gh-pages@v4 + 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' --skip-duplicate diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000000..508d210158 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,46 @@ +name: "CodeQL" + +on: + push: + branches: [ 'master', 'release/**' ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ 'master', 'release/**' ] + schedule: + - cron: '0 0 * * 5' + +jobs: + analyze: + name: Analyze + runs-on: 'ubuntu-latest' + timeout-minutes: 60 + permissions: + actions: read + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: [ 'csharp' ] + steps: + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.* + 9.0.* + - name: Git checkout + uses: actions/checkout@v4 + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + - name: Restore .NET tools + run: | + dotnet tool restore + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/deps-review.yml b/.github/workflows/deps-review.yml new file mode 100644 index 0000000000..b9d6d20fff --- /dev/null +++ b/.github/workflows/deps-review.yml @@ -0,0 +1,14 @@ +name: 'Dependency Review' +on: [pull_request] + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: 'Checkout Repository' + uses: actions/checkout@v4 + - name: 'Dependency Review' + uses: actions/dependency-review-action@v4 diff --git a/.github/workflows/qodana.yml b/.github/workflows/qodana.yml new file mode 100644 index 0000000000..8ce0acd5db --- /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@v2024.1 + env: + QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} + - name: Upload results to artifacts on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: qodana_results + path: ${{ runner.temp }}/qodana/results diff --git a/.gitignore b/.gitignore index 2bd200a72d..c1757fc159 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. ## -## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore # User-specific files *.rsuser @@ -90,6 +90,7 @@ StyleCopReport.xml *.tmp_proj *_wpftmp.csproj *.log +*.tlog *.vspscc *.vssscc .builds @@ -97,9 +98,6 @@ StyleCopReport.xml *.svclog *.scc -# MacOS file systems -**/.DS_STORE - # Chutzpah Test files _Chutzpah* @@ -134,9 +132,6 @@ _ReSharper*/ *.[Rr]e[Ss]harper *.DotSettings.user -# JetBrains Rider -.idea/ - # TeamCity is a build add-in _TeamCity* @@ -148,7 +143,9 @@ _TeamCity* !.axoCover/settings.json # Coverlet is a free, cross platform Code Coverage Tool -coverage*[.json, .xml, .info] +coverage*.json +coverage*.xml +coverage*.info # Visual Studio code coverage results *.coverage @@ -297,6 +294,17 @@ node_modules/ # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) *.vbw +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + # Visual Studio LightSwitch build output **/*.HTMLClient/GeneratedArtifacts **/*.DesktopClient/GeneratedArtifacts @@ -353,6 +361,9 @@ ASALocalRun/ # Local History for Visual Studio .localhistory/ +# Visual Studio History (VSHistory) files +.vshistory/ + # BeatPulse healthcheck temp database healthchecksdb @@ -365,5 +376,53 @@ MigrationBackup/ # Fody - auto-generated XML schema FodyWeavers.xsd +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml + +############################################# +### Additions specific to this repository ### +############################################# + +# MacOS file systems +**/.DS_STORE + # Sqlite example databases *.db + +# JetBrains IDEs Rider/IntelliJ (based on https://intellij-support.jetbrains.com/hc/en-us/articles/206544839) +**/.idea/**/*.xml +**/.idea/**/*.iml +**/.idea/**/*.ids +**/.idea/**/*.ipr +**/.idea/**/*.iws +**/.idea/**/*.name +**/.idea/**/*.properties +**/.idea/**/*.ser +**/.idea/**/shelf/ +**/.idea/**/dictionaries/ +**/.idea/**/libraries/ +**/.idea/**/artifacts/ +**/.idea/**/httpRequests/ +**/.idea/**/dataSources/ +!**/.idea/**/codeStyles/* + +# Workaround for https://github.com/microsoft/kiota/issues/4228 +kiota-lock.json diff --git a/.idea/.idea.JsonApiDotNetCore/.idea/.gitignore b/.idea/.idea.JsonApiDotNetCore/.idea/.gitignore new file mode 100644 index 0000000000..3933e947a2 --- /dev/null +++ b/.idea/.idea.JsonApiDotNetCore/.idea/.gitignore @@ -0,0 +1 @@ +# Empty .gitignore file to prevent Rider from adding one diff --git a/.idea/.idea.JsonApiDotNetCore/.idea/codeStyles/Project.xml b/.idea/.idea.JsonApiDotNetCore/.idea/codeStyles/Project.xml new file mode 100644 index 0000000000..902b9f865f --- /dev/null +++ b/.idea/.idea.JsonApiDotNetCore/.idea/codeStyles/Project.xml @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/.idea/.idea.JsonApiDotNetCore/.idea/codeStyles/codeStyleConfig.xml b/.idea/.idea.JsonApiDotNetCore/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000000..405cd65360 --- /dev/null +++ b/.idea/.idea.JsonApiDotNetCore/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + diff --git a/Build.ps1 b/Build.ps1 index e2e638fafa..1c369bd1af 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -1,111 +1,26 @@ -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') - # passing --build instead of --no-build as workaround for https://youtrack.jetbrains.com/issue/RSRP-487054 - dotnet jb inspectcode JsonApiDotNetCore.sln --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) $($_.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) { - Write-Output "Running code cleanup on changed files in pull request" - - # 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 the latest version. - $mergeCommitHash = git rev-parse "HEAD" - $targetCommitHash = git rev-parse "$env:APPVEYOR_REPO_BRANCH" - - dotnet regitlint -s JsonApiDotNetCore.sln --print-command --disable-jb-path-hack --jb --profile='\"JADNC Full Cleanup\"' --jb --properties:Configuration=Release --jb --verbosity=WARN -f commits -a $mergeCommitHash -b $targetCommitHash --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 - } +Write-Host "$(pwsh --version)" +Write-Host ".NET SDK $(dotnet --version)" - 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 -} +Remove-Item -Recurse -Force artifacts -ErrorAction SilentlyContinue +Remove-Item -Recurse -Force * -Include coverage.cobertura.xml dotnet tool restore -CheckLastExitCode - -dotnet build -c Release -CheckLastExitCode +VerifySuccessExitCode -RunInspectCode -RunCleanupCode +dotnet build --configuration Release +VerifySuccessExitCode -dotnet test -c Release --no-build --collect:"XPlat Code Coverage" -CheckLastExitCode +dotnet test --no-build --configuration Release --verbosity quiet --collect:"XPlat Code Coverage" +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 +VerifySuccessExitCode diff --git a/CSharpGuidelinesAnalyzer.config b/CSharpGuidelinesAnalyzer.config index 89b568e155..6d5453159a 100644 --- a/CSharpGuidelinesAnalyzer.config +++ b/CSharpGuidelinesAnalyzer.config @@ -1,5 +1,5 @@ - + diff --git a/CodingGuidelines.ruleset b/CodingGuidelines.ruleset index e647ad9e58..b29d7423b4 100644 --- a/CodingGuidelines.ruleset +++ b/CodingGuidelines.ruleset @@ -1,32 +1,54 @@  - + + + - - - - - - + + - - - + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index 5b311343c7..1ef255f56e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,40 +1,61 @@ - net6.0 - 6.0.* - 6.0.* - 6.0.* - 4.1.* - 2.14.1 - 5.0.1 - $(MSBuildThisFileDirectory)CodingGuidelines.ruleset - 9999 enable + latest enable false false + true + Recommended + $(MSBuildThisFileDirectory)CodingGuidelines.ruleset + $(MSBuildThisFileDirectory)tests.runsettings + 5.7.2 + pre + 3 + direct - - - - - + + + IDE0028;IDE0300;IDE0301;IDE0302;IDE0303;IDE0304;IDE0305;IDE0306 + $(NoWarn);$(UseCollectionExpressionRules) + + + $(NoWarn);NETSDK1215 + - + + $(NoWarn);AV2210 + + + $(NoWarn);1591 true true - - $(NoWarn);AV2210 + + true - - - 3.1.2 - 4.17.2 - 17.1.0 + + $(NoWarn);CA1707;CA1062 + + + + $(NoWarn);CA1062 + + + + + + diff --git a/JetBrainsInspectCodeTransform.xslt b/JetBrainsInspectCodeTransform.xslt index 098821f29f..28fa772b0f 100644 --- a/JetBrainsInspectCodeTransform.xslt +++ b/JetBrainsInspectCodeTransform.xslt @@ -25,6 +25,7 @@ File Line Number + Type Message @@ -35,6 +36,9 @@ + + + diff --git a/JsonApiDotNetCore.sln b/JsonApiDotNetCore.sln index 0a8ed12d2a..793c01950d 100644 --- a/JsonApiDotNetCore.sln +++ b/JsonApiDotNetCore.sln @@ -10,8 +10,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig .gitignore = .gitignore + .github\workflows\build.yml = .github\workflows\build.yml + CodingGuidelines.ruleset = CodingGuidelines.ruleset CSharpGuidelinesAnalyzer.config = CSharpGuidelinesAnalyzer.config Directory.Build.props = Directory.Build.props + package-versions.props = package-versions.props + tests.runsettings = tests.runsettings EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Examples", "Examples", "{026FBC6C-AF76-4568-9B87-EC73457899FD}" @@ -46,14 +50,36 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestBuildingBlocks", "test\ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JsonApiDotNetCore.SourceGenerators", "src\JsonApiDotNetCore.SourceGenerators\JsonApiDotNetCore.SourceGenerators.csproj", "{952C0FDE-AFC8-455C-986F-6CC882ED8953}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SourceGeneratorDebugger", "test\SourceGeneratorDebugger\SourceGeneratorDebugger.csproj", "{87D066F9-3540-4AC7-A748-134900969EE5}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SourceGeneratorTests", "test\SourceGeneratorTests\SourceGeneratorTests.csproj", "{0E0B5C51-F7E2-4F40-A4E4-DED0E9731DC9}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JsonApiDotNetCore.Annotations", "src\JsonApiDotNetCore.Annotations\JsonApiDotNetCore.Annotations.csproj", "{83FF097C-C8C6-477B-9FAB-DF99B84978B5}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DatabasePerTenantExample", "src\Examples\DatabasePerTenantExample\DatabasePerTenantExample.csproj", "{60334658-BE51-43B3-9C4D-F2BBF56C89CE}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AnnotationTests", "test\AnnotationTests\AnnotationTests.csproj", "{24B0C12F-38CD-4245-8785-87BEFAD55B00}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DapperExample", "src\Examples\DapperExample\DapperExample.csproj", "{C1774117-5073-4DF8-B5BE-BF7B538BD1C2}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DapperTests", "test\DapperTests\DapperTests.csproj", "{80E322F5-5F5D-4670-A30F-02D33C2C7900}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JsonApiDotNetCore.OpenApi.Swashbuckle", "src\JsonApiDotNetCore.OpenApi.Swashbuckle\JsonApiDotNetCore.OpenApi.Swashbuckle.csproj", "{71287D6F-6C3B-44B4-9FCA-E78FE3F02289}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenApiTests", "test\OpenApiTests\OpenApiTests.csproj", "{B693DE14-BB28-496F-AB39-B4E674ABCA80}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JsonApiDotNetCore.OpenApi.Client.NSwag", "src\JsonApiDotNetCore.OpenApi.Client.NSwag\JsonApiDotNetCore.OpenApi.Client.NSwag.csproj", "{5ADAA902-5A75-4ECB-B4B4-03291D63CE9C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenApiNSwagClientExample", "src\Examples\OpenApiNSwagClientExample\OpenApiNSwagClientExample.csproj", "{7FC5DFA3-6F66-4FD8-820D-81E93856F252}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenApiNSwagClientTests", "test\OpenApiNSwagClientTests\OpenApiNSwagClientTests.csproj", "{77F98215-3085-422E-B99D-4C404C2114CF}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenApiNSwagEndToEndTests", "test\OpenApiNSwagEndToEndTests\OpenApiNSwagEndToEndTests.csproj", "{3BA4F9B9-3D90-44B5-B09C-28D98E0B4225}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JsonApiDotNetCore.OpenApi.Client.Kiota", "src\JsonApiDotNetCore.OpenApi.Client.Kiota\JsonApiDotNetCore.OpenApi.Client.Kiota.csproj", "{617FCA5D-A2DE-4083-B373-ADCA9901059F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenApiKiotaClientExample", "src\Examples\OpenApiKiotaClientExample\OpenApiKiotaClientExample.csproj", "{39DEAFE8-AE29-48E5-A67D-73776D70FC82}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenApiKiotaEndToEndTests", "test\OpenApiKiotaEndToEndTests\OpenApiKiotaEndToEndTests.csproj", "{FD86C676-3D80-4971-8D8C-B0729B2251F6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -232,18 +258,6 @@ Global {952C0FDE-AFC8-455C-986F-6CC882ED8953}.Release|x64.Build.0 = Release|Any CPU {952C0FDE-AFC8-455C-986F-6CC882ED8953}.Release|x86.ActiveCfg = Release|Any CPU {952C0FDE-AFC8-455C-986F-6CC882ED8953}.Release|x86.Build.0 = Release|Any CPU - {87D066F9-3540-4AC7-A748-134900969EE5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {87D066F9-3540-4AC7-A748-134900969EE5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {87D066F9-3540-4AC7-A748-134900969EE5}.Debug|x64.ActiveCfg = Debug|Any CPU - {87D066F9-3540-4AC7-A748-134900969EE5}.Debug|x64.Build.0 = Debug|Any CPU - {87D066F9-3540-4AC7-A748-134900969EE5}.Debug|x86.ActiveCfg = Debug|Any CPU - {87D066F9-3540-4AC7-A748-134900969EE5}.Debug|x86.Build.0 = Debug|Any CPU - {87D066F9-3540-4AC7-A748-134900969EE5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {87D066F9-3540-4AC7-A748-134900969EE5}.Release|Any CPU.Build.0 = Release|Any CPU - {87D066F9-3540-4AC7-A748-134900969EE5}.Release|x64.ActiveCfg = Release|Any CPU - {87D066F9-3540-4AC7-A748-134900969EE5}.Release|x64.Build.0 = Release|Any CPU - {87D066F9-3540-4AC7-A748-134900969EE5}.Release|x86.ActiveCfg = Release|Any CPU - {87D066F9-3540-4AC7-A748-134900969EE5}.Release|x86.Build.0 = Release|Any CPU {0E0B5C51-F7E2-4F40-A4E4-DED0E9731DC9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0E0B5C51-F7E2-4F40-A4E4-DED0E9731DC9}.Debug|Any CPU.Build.0 = Debug|Any CPU {0E0B5C51-F7E2-4F40-A4E4-DED0E9731DC9}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -280,6 +294,150 @@ Global {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Release|x64.Build.0 = Release|Any CPU {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Release|x86.ActiveCfg = Release|Any CPU {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Release|x86.Build.0 = Release|Any CPU + {24B0C12F-38CD-4245-8785-87BEFAD55B00}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {24B0C12F-38CD-4245-8785-87BEFAD55B00}.Debug|Any CPU.Build.0 = Debug|Any CPU + {24B0C12F-38CD-4245-8785-87BEFAD55B00}.Debug|x64.ActiveCfg = Debug|Any CPU + {24B0C12F-38CD-4245-8785-87BEFAD55B00}.Debug|x64.Build.0 = Debug|Any CPU + {24B0C12F-38CD-4245-8785-87BEFAD55B00}.Debug|x86.ActiveCfg = Debug|Any CPU + {24B0C12F-38CD-4245-8785-87BEFAD55B00}.Debug|x86.Build.0 = Debug|Any CPU + {24B0C12F-38CD-4245-8785-87BEFAD55B00}.Release|Any CPU.ActiveCfg = Release|Any CPU + {24B0C12F-38CD-4245-8785-87BEFAD55B00}.Release|Any CPU.Build.0 = Release|Any CPU + {24B0C12F-38CD-4245-8785-87BEFAD55B00}.Release|x64.ActiveCfg = Release|Any CPU + {24B0C12F-38CD-4245-8785-87BEFAD55B00}.Release|x64.Build.0 = Release|Any CPU + {24B0C12F-38CD-4245-8785-87BEFAD55B00}.Release|x86.ActiveCfg = Release|Any CPU + {24B0C12F-38CD-4245-8785-87BEFAD55B00}.Release|x86.Build.0 = Release|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Debug|x64.ActiveCfg = Debug|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Debug|x64.Build.0 = Debug|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Debug|x86.ActiveCfg = Debug|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Debug|x86.Build.0 = Debug|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Release|Any CPU.Build.0 = Release|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Release|x64.ActiveCfg = Release|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Release|x64.Build.0 = Release|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Release|x86.ActiveCfg = Release|Any CPU + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2}.Release|x86.Build.0 = Release|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Debug|Any CPU.Build.0 = Debug|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Debug|x64.ActiveCfg = Debug|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Debug|x64.Build.0 = Debug|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Debug|x86.ActiveCfg = Debug|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Debug|x86.Build.0 = Debug|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Release|Any CPU.ActiveCfg = Release|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Release|Any CPU.Build.0 = Release|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Release|x64.ActiveCfg = Release|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Release|x64.Build.0 = Release|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Release|x86.ActiveCfg = Release|Any CPU + {80E322F5-5F5D-4670-A30F-02D33C2C7900}.Release|x86.Build.0 = Release|Any CPU + {71287D6F-6C3B-44B4-9FCA-E78FE3F02289}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {71287D6F-6C3B-44B4-9FCA-E78FE3F02289}.Debug|Any CPU.Build.0 = Debug|Any CPU + {71287D6F-6C3B-44B4-9FCA-E78FE3F02289}.Debug|x64.ActiveCfg = Debug|Any CPU + {71287D6F-6C3B-44B4-9FCA-E78FE3F02289}.Debug|x64.Build.0 = Debug|Any CPU + {71287D6F-6C3B-44B4-9FCA-E78FE3F02289}.Debug|x86.ActiveCfg = Debug|Any CPU + {71287D6F-6C3B-44B4-9FCA-E78FE3F02289}.Debug|x86.Build.0 = Debug|Any CPU + {71287D6F-6C3B-44B4-9FCA-E78FE3F02289}.Release|Any CPU.ActiveCfg = Release|Any CPU + {71287D6F-6C3B-44B4-9FCA-E78FE3F02289}.Release|Any CPU.Build.0 = Release|Any CPU + {71287D6F-6C3B-44B4-9FCA-E78FE3F02289}.Release|x64.ActiveCfg = Release|Any CPU + {71287D6F-6C3B-44B4-9FCA-E78FE3F02289}.Release|x64.Build.0 = Release|Any CPU + {71287D6F-6C3B-44B4-9FCA-E78FE3F02289}.Release|x86.ActiveCfg = Release|Any CPU + {71287D6F-6C3B-44B4-9FCA-E78FE3F02289}.Release|x86.Build.0 = Release|Any CPU + {B693DE14-BB28-496F-AB39-B4E674ABCA80}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B693DE14-BB28-496F-AB39-B4E674ABCA80}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B693DE14-BB28-496F-AB39-B4E674ABCA80}.Debug|x64.ActiveCfg = Debug|Any CPU + {B693DE14-BB28-496F-AB39-B4E674ABCA80}.Debug|x64.Build.0 = Debug|Any CPU + {B693DE14-BB28-496F-AB39-B4E674ABCA80}.Debug|x86.ActiveCfg = Debug|Any CPU + {B693DE14-BB28-496F-AB39-B4E674ABCA80}.Debug|x86.Build.0 = Debug|Any CPU + {B693DE14-BB28-496F-AB39-B4E674ABCA80}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B693DE14-BB28-496F-AB39-B4E674ABCA80}.Release|Any CPU.Build.0 = Release|Any CPU + {B693DE14-BB28-496F-AB39-B4E674ABCA80}.Release|x64.ActiveCfg = Release|Any CPU + {B693DE14-BB28-496F-AB39-B4E674ABCA80}.Release|x64.Build.0 = Release|Any CPU + {B693DE14-BB28-496F-AB39-B4E674ABCA80}.Release|x86.ActiveCfg = Release|Any CPU + {B693DE14-BB28-496F-AB39-B4E674ABCA80}.Release|x86.Build.0 = Release|Any CPU + {5ADAA902-5A75-4ECB-B4B4-03291D63CE9C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5ADAA902-5A75-4ECB-B4B4-03291D63CE9C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5ADAA902-5A75-4ECB-B4B4-03291D63CE9C}.Debug|x64.ActiveCfg = Debug|Any CPU + {5ADAA902-5A75-4ECB-B4B4-03291D63CE9C}.Debug|x64.Build.0 = Debug|Any CPU + {5ADAA902-5A75-4ECB-B4B4-03291D63CE9C}.Debug|x86.ActiveCfg = Debug|Any CPU + {5ADAA902-5A75-4ECB-B4B4-03291D63CE9C}.Debug|x86.Build.0 = Debug|Any CPU + {5ADAA902-5A75-4ECB-B4B4-03291D63CE9C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5ADAA902-5A75-4ECB-B4B4-03291D63CE9C}.Release|Any CPU.Build.0 = Release|Any CPU + {5ADAA902-5A75-4ECB-B4B4-03291D63CE9C}.Release|x64.ActiveCfg = Release|Any CPU + {5ADAA902-5A75-4ECB-B4B4-03291D63CE9C}.Release|x64.Build.0 = Release|Any CPU + {5ADAA902-5A75-4ECB-B4B4-03291D63CE9C}.Release|x86.ActiveCfg = Release|Any CPU + {5ADAA902-5A75-4ECB-B4B4-03291D63CE9C}.Release|x86.Build.0 = Release|Any CPU + {7FC5DFA3-6F66-4FD8-820D-81E93856F252}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7FC5DFA3-6F66-4FD8-820D-81E93856F252}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7FC5DFA3-6F66-4FD8-820D-81E93856F252}.Debug|x64.ActiveCfg = Debug|Any CPU + {7FC5DFA3-6F66-4FD8-820D-81E93856F252}.Debug|x64.Build.0 = Debug|Any CPU + {7FC5DFA3-6F66-4FD8-820D-81E93856F252}.Debug|x86.ActiveCfg = Debug|Any CPU + {7FC5DFA3-6F66-4FD8-820D-81E93856F252}.Debug|x86.Build.0 = Debug|Any CPU + {7FC5DFA3-6F66-4FD8-820D-81E93856F252}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7FC5DFA3-6F66-4FD8-820D-81E93856F252}.Release|Any CPU.Build.0 = Release|Any CPU + {7FC5DFA3-6F66-4FD8-820D-81E93856F252}.Release|x64.ActiveCfg = Release|Any CPU + {7FC5DFA3-6F66-4FD8-820D-81E93856F252}.Release|x64.Build.0 = Release|Any CPU + {7FC5DFA3-6F66-4FD8-820D-81E93856F252}.Release|x86.ActiveCfg = Release|Any CPU + {7FC5DFA3-6F66-4FD8-820D-81E93856F252}.Release|x86.Build.0 = Release|Any CPU + {77F98215-3085-422E-B99D-4C404C2114CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {77F98215-3085-422E-B99D-4C404C2114CF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {77F98215-3085-422E-B99D-4C404C2114CF}.Debug|x64.ActiveCfg = Debug|Any CPU + {77F98215-3085-422E-B99D-4C404C2114CF}.Debug|x64.Build.0 = Debug|Any CPU + {77F98215-3085-422E-B99D-4C404C2114CF}.Debug|x86.ActiveCfg = Debug|Any CPU + {77F98215-3085-422E-B99D-4C404C2114CF}.Debug|x86.Build.0 = Debug|Any CPU + {77F98215-3085-422E-B99D-4C404C2114CF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {77F98215-3085-422E-B99D-4C404C2114CF}.Release|Any CPU.Build.0 = Release|Any CPU + {77F98215-3085-422E-B99D-4C404C2114CF}.Release|x64.ActiveCfg = Release|Any CPU + {77F98215-3085-422E-B99D-4C404C2114CF}.Release|x64.Build.0 = Release|Any CPU + {77F98215-3085-422E-B99D-4C404C2114CF}.Release|x86.ActiveCfg = Release|Any CPU + {77F98215-3085-422E-B99D-4C404C2114CF}.Release|x86.Build.0 = Release|Any CPU + {3BA4F9B9-3D90-44B5-B09C-28D98E0B4225}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3BA4F9B9-3D90-44B5-B09C-28D98E0B4225}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3BA4F9B9-3D90-44B5-B09C-28D98E0B4225}.Debug|x64.ActiveCfg = Debug|Any CPU + {3BA4F9B9-3D90-44B5-B09C-28D98E0B4225}.Debug|x64.Build.0 = Debug|Any CPU + {3BA4F9B9-3D90-44B5-B09C-28D98E0B4225}.Debug|x86.ActiveCfg = Debug|Any CPU + {3BA4F9B9-3D90-44B5-B09C-28D98E0B4225}.Debug|x86.Build.0 = Debug|Any CPU + {3BA4F9B9-3D90-44B5-B09C-28D98E0B4225}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3BA4F9B9-3D90-44B5-B09C-28D98E0B4225}.Release|Any CPU.Build.0 = Release|Any CPU + {3BA4F9B9-3D90-44B5-B09C-28D98E0B4225}.Release|x64.ActiveCfg = Release|Any CPU + {3BA4F9B9-3D90-44B5-B09C-28D98E0B4225}.Release|x64.Build.0 = Release|Any CPU + {3BA4F9B9-3D90-44B5-B09C-28D98E0B4225}.Release|x86.ActiveCfg = Release|Any CPU + {3BA4F9B9-3D90-44B5-B09C-28D98E0B4225}.Release|x86.Build.0 = Release|Any CPU + {617FCA5D-A2DE-4083-B373-ADCA9901059F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {617FCA5D-A2DE-4083-B373-ADCA9901059F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {617FCA5D-A2DE-4083-B373-ADCA9901059F}.Debug|x64.ActiveCfg = Debug|Any CPU + {617FCA5D-A2DE-4083-B373-ADCA9901059F}.Debug|x64.Build.0 = Debug|Any CPU + {617FCA5D-A2DE-4083-B373-ADCA9901059F}.Debug|x86.ActiveCfg = Debug|Any CPU + {617FCA5D-A2DE-4083-B373-ADCA9901059F}.Debug|x86.Build.0 = Debug|Any CPU + {617FCA5D-A2DE-4083-B373-ADCA9901059F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {617FCA5D-A2DE-4083-B373-ADCA9901059F}.Release|Any CPU.Build.0 = Release|Any CPU + {617FCA5D-A2DE-4083-B373-ADCA9901059F}.Release|x64.ActiveCfg = Release|Any CPU + {617FCA5D-A2DE-4083-B373-ADCA9901059F}.Release|x64.Build.0 = Release|Any CPU + {617FCA5D-A2DE-4083-B373-ADCA9901059F}.Release|x86.ActiveCfg = Release|Any CPU + {617FCA5D-A2DE-4083-B373-ADCA9901059F}.Release|x86.Build.0 = Release|Any CPU + {39DEAFE8-AE29-48E5-A67D-73776D70FC82}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {39DEAFE8-AE29-48E5-A67D-73776D70FC82}.Debug|Any CPU.Build.0 = Debug|Any CPU + {39DEAFE8-AE29-48E5-A67D-73776D70FC82}.Debug|x64.ActiveCfg = Debug|Any CPU + {39DEAFE8-AE29-48E5-A67D-73776D70FC82}.Debug|x64.Build.0 = Debug|Any CPU + {39DEAFE8-AE29-48E5-A67D-73776D70FC82}.Debug|x86.ActiveCfg = Debug|Any CPU + {39DEAFE8-AE29-48E5-A67D-73776D70FC82}.Debug|x86.Build.0 = Debug|Any CPU + {39DEAFE8-AE29-48E5-A67D-73776D70FC82}.Release|Any CPU.ActiveCfg = Release|Any CPU + {39DEAFE8-AE29-48E5-A67D-73776D70FC82}.Release|Any CPU.Build.0 = Release|Any CPU + {39DEAFE8-AE29-48E5-A67D-73776D70FC82}.Release|x64.ActiveCfg = Release|Any CPU + {39DEAFE8-AE29-48E5-A67D-73776D70FC82}.Release|x64.Build.0 = Release|Any CPU + {39DEAFE8-AE29-48E5-A67D-73776D70FC82}.Release|x86.ActiveCfg = Release|Any CPU + {39DEAFE8-AE29-48E5-A67D-73776D70FC82}.Release|x86.Build.0 = Release|Any CPU + {FD86C676-3D80-4971-8D8C-B0729B2251F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FD86C676-3D80-4971-8D8C-B0729B2251F6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FD86C676-3D80-4971-8D8C-B0729B2251F6}.Debug|x64.ActiveCfg = Debug|Any CPU + {FD86C676-3D80-4971-8D8C-B0729B2251F6}.Debug|x64.Build.0 = Debug|Any CPU + {FD86C676-3D80-4971-8D8C-B0729B2251F6}.Debug|x86.ActiveCfg = Debug|Any CPU + {FD86C676-3D80-4971-8D8C-B0729B2251F6}.Debug|x86.Build.0 = Debug|Any CPU + {FD86C676-3D80-4971-8D8C-B0729B2251F6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FD86C676-3D80-4971-8D8C-B0729B2251F6}.Release|Any CPU.Build.0 = Release|Any CPU + {FD86C676-3D80-4971-8D8C-B0729B2251F6}.Release|x64.ActiveCfg = Release|Any CPU + {FD86C676-3D80-4971-8D8C-B0729B2251F6}.Release|x64.Build.0 = Release|Any CPU + {FD86C676-3D80-4971-8D8C-B0729B2251F6}.Release|x86.ActiveCfg = Release|Any CPU + {FD86C676-3D80-4971-8D8C-B0729B2251F6}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -299,10 +457,21 @@ Global {EC3202C6-1D4C-4B14-A599-B9D3F27FE3BA} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} {952C0FDE-AFC8-455C-986F-6CC882ED8953} = {7A2B7ADD-ECB5-4D00-AA6A-D45BD11C97CF} - {87D066F9-3540-4AC7-A748-134900969EE5} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} {0E0B5C51-F7E2-4F40-A4E4-DED0E9731DC9} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} {83FF097C-C8C6-477B-9FAB-DF99B84978B5} = {7A2B7ADD-ECB5-4D00-AA6A-D45BD11C97CF} {60334658-BE51-43B3-9C4D-F2BBF56C89CE} = {026FBC6C-AF76-4568-9B87-EC73457899FD} + {24B0C12F-38CD-4245-8785-87BEFAD55B00} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} + {C1774117-5073-4DF8-B5BE-BF7B538BD1C2} = {026FBC6C-AF76-4568-9B87-EC73457899FD} + {80E322F5-5F5D-4670-A30F-02D33C2C7900} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} + {71287D6F-6C3B-44B4-9FCA-E78FE3F02289} = {7A2B7ADD-ECB5-4D00-AA6A-D45BD11C97CF} + {B693DE14-BB28-496F-AB39-B4E674ABCA80} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} + {5ADAA902-5A75-4ECB-B4B4-03291D63CE9C} = {7A2B7ADD-ECB5-4D00-AA6A-D45BD11C97CF} + {7FC5DFA3-6F66-4FD8-820D-81E93856F252} = {026FBC6C-AF76-4568-9B87-EC73457899FD} + {77F98215-3085-422E-B99D-4C404C2114CF} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} + {3BA4F9B9-3D90-44B5-B09C-28D98E0B4225} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} + {617FCA5D-A2DE-4083-B373-ADCA9901059F} = {7A2B7ADD-ECB5-4D00-AA6A-D45BD11C97CF} + {39DEAFE8-AE29-48E5-A67D-73776D70FC82} = {026FBC6C-AF76-4568-9B87-EC73457899FD} + {FD86C676-3D80-4971-8D8C-B0729B2251F6} = {24B15015-62E5-42E1-9BA0-ECE6BE7AA15F} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {A2421882-8F0A-4905-928F-B550B192F9A4} diff --git a/JsonApiDotNetCore.sln.DotSettings b/JsonApiDotNetCore.sln.DotSettings index 2a7eb28d9b..6c29a58aef 100644 --- a/JsonApiDotNetCore.sln.DotSettings +++ b/JsonApiDotNetCore.sln.DotSettings @@ -1,19 +1,12 @@  - // Use the following placeholders: -// $EXPR$ -- source expression -// $NAME$ -- source name (string literal or 'nameof' expression) -// $MESSAGE$ -- string literal in the form of "$NAME$ != null" -JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$, $NAME$); - 199 - 5000 - 99 - 100 - 200 - 1000 - 500 + 5000 + 2000 3000 - 50 False + True + 83FF097C-C8C6-477B-9FAB-DF99B84978B5/f:ReadOnlySet.cs + swagger.g.json + swagger.json SOLUTION True True @@ -28,6 +21,7 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$, $NAME$); SUGGESTION SUGGESTION WARNING + WARNING SUGGESTION SUGGESTION SUGGESTION @@ -53,16 +47,21 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$, $NAME$); WARNING WARNING WARNING + DO_NOT_SHOW WARNING + SUGGESTION + HINT + WARNING + SUGGESTION DO_NOT_SHOW HINT SUGGESTION - WARNING - WARNING + SUGGESTION + SUGGESTION WARNING WARNING - SUGGESTION WARNING + SUGGESTION SUGGESTION SUGGESTION DO_NOT_SHOW @@ -74,6 +73,8 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$, $NAME$); SUGGESTION SUGGESTION SUGGESTION + WARNING + DO_NOT_SHOW WARNING WARNING WARNING @@ -86,8 +87,14 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$, $NAME$); WARNING WARNING WARNING + SUGGESTION + SUGGESTION + SUGGESTION WARNING - <?xml version="1.0" encoding="utf-16"?><Profile name="JADNC Full Cleanup"><XMLReformatCode>True</XMLReformatCode><CSCodeStyleAttributes ArrangeTypeAccessModifier="True" ArrangeTypeMemberAccessModifier="True" SortModifiers="True" RemoveRedundantParentheses="True" AddMissingParentheses="True" ArrangeBraces="True" ArrangeAttributes="True" ArrangeArgumentsStyle="True" ArrangeCodeBodyStyle="True" ArrangeVarStyle="True" ArrangeTrailingCommas="True" ArrangeObjectCreation="True" ArrangeDefaultValue="True" ArrangeNamespaces="True" /><CssAlphabetizeProperties>True</CssAlphabetizeProperties><JsInsertSemicolon>True</JsInsertSemicolon><FormatAttributeQuoteDescriptor>True</FormatAttributeQuoteDescriptor><CorrectVariableKindsDescriptor>True</CorrectVariableKindsDescriptor><VariablesToInnerScopesDescriptor>True</VariablesToInnerScopesDescriptor><StringToTemplatesDescriptor>True</StringToTemplatesDescriptor><JsReformatCode>True</JsReformatCode><JsFormatDocComments>True</JsFormatDocComments><RemoveRedundantQualifiersTs>True</RemoveRedundantQualifiersTs><OptimizeImportsTs>True</OptimizeImportsTs><OptimizeReferenceCommentsTs>True</OptimizeReferenceCommentsTs><PublicModifierStyleTs>True</PublicModifierStyleTs><ExplicitAnyTs>True</ExplicitAnyTs><TypeAnnotationStyleTs>True</TypeAnnotationStyleTs><RelativePathStyleTs>True</RelativePathStyleTs><AsInsteadOfCastTs>True</AsInsteadOfCastTs><HtmlReformatCode>True</HtmlReformatCode><AspOptimizeRegisterDirectives>True</AspOptimizeRegisterDirectives><RemoveCodeRedundancies>True</RemoveCodeRedundancies><CSUseAutoProperty>True</CSUseAutoProperty><CSMakeFieldReadonly>True</CSMakeFieldReadonly><CSMakeAutoPropertyGetOnly>True</CSMakeAutoPropertyGetOnly><CSArrangeQualifiers>True</CSArrangeQualifiers><CSFixBuiltinTypeReferences>True</CSFixBuiltinTypeReferences><CssReformatCode>True</CssReformatCode><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings></CSOptimizeUsings><CSShortenReferences>True</CSShortenReferences><CSReformatCode>True</CSReformatCode><CSharpFormatDocComments>True</CSharpFormatDocComments><CSReorderTypeMembers>True</CSReorderTypeMembers><XAMLCollapseEmptyTags>False</XAMLCollapseEmptyTags></Profile> + True + SUGGESTION + False + <?xml version="1.0" encoding="utf-16"?><Profile name="JADNC Full Cleanup"><XMLReformatCode>True</XMLReformatCode><CSCodeStyleAttributes ArrangeTypeAccessModifier="True" ArrangeTypeMemberAccessModifier="True" SortModifiers="True" RemoveRedundantParentheses="True" AddMissingParentheses="True" ArrangeBraces="True" ArrangeAttributes="True" ArrangeArgumentsStyle="True" ArrangeCodeBodyStyle="True" ArrangeVarStyle="True" ArrangeTrailingCommas="True" ArrangeObjectCreation="True" ArrangeDefaultValue="True" ArrangeNamespaces="True" ArrangeNullCheckingPattern="True" /><CssAlphabetizeProperties>True</CssAlphabetizeProperties><JsInsertSemicolon>True</JsInsertSemicolon><FormatAttributeQuoteDescriptor>True</FormatAttributeQuoteDescriptor><CorrectVariableKindsDescriptor>True</CorrectVariableKindsDescriptor><VariablesToInnerScopesDescriptor>True</VariablesToInnerScopesDescriptor><StringToTemplatesDescriptor>True</StringToTemplatesDescriptor><JsReformatCode>True</JsReformatCode><JsFormatDocComments>True</JsFormatDocComments><RemoveRedundantQualifiersTs>True</RemoveRedundantQualifiersTs><OptimizeImportsTs>True</OptimizeImportsTs><OptimizeReferenceCommentsTs>True</OptimizeReferenceCommentsTs><PublicModifierStyleTs>True</PublicModifierStyleTs><ExplicitAnyTs>True</ExplicitAnyTs><TypeAnnotationStyleTs>True</TypeAnnotationStyleTs><RelativePathStyleTs>True</RelativePathStyleTs><AsInsteadOfCastTs>True</AsInsteadOfCastTs><HtmlReformatCode>True</HtmlReformatCode><AspOptimizeRegisterDirectives>True</AspOptimizeRegisterDirectives><RemoveCodeRedundancies>True</RemoveCodeRedundancies><CSUseAutoProperty>True</CSUseAutoProperty><CSMakeFieldReadonly>True</CSMakeFieldReadonly><CSMakeAutoPropertyGetOnly>True</CSMakeAutoPropertyGetOnly><CSArrangeQualifiers>True</CSArrangeQualifiers><CSFixBuiltinTypeReferences>True</CSFixBuiltinTypeReferences><CssReformatCode>True</CssReformatCode><CSOptimizeUsings><OptimizeUsings>True</OptimizeUsings></CSOptimizeUsings><CSShortenReferences>True</CSShortenReferences><CSReformatCode>True</CSReformatCode><CSharpFormatDocComments>True</CSharpFormatDocComments><CSReorderTypeMembers>True</CSReorderTypeMembers><XAMLCollapseEmptyTags>False</XAMLCollapseEmptyTags><CSReformatInactiveBranches>True</CSReformatInactiveBranches></Profile> JADNC Full Cleanup Required Required @@ -107,6 +114,7 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$, $NAME$); True True True + INDENT 1 1 False @@ -114,14 +122,17 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$, $NAME$); False False False + False False False False True + 1 NEVER NEVER False NEVER + False False False NEVER @@ -132,14 +143,17 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$, $NAME$); False False CHOP_ALWAYS + False True True True WRAP_IF_LONG 160 + CHOP_IF_LONG WRAP_IF_LONG CHOP_ALWAYS CHOP_ALWAYS + WRAP_IF_LONG True True 2 @@ -572,13 +586,17 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$, $NAME$); False <Policy Inspect="True" Prefix="_" Suffix="" Style="aaBb" /> <Policy Inspect="True" Prefix="_" Suffix="" Style="aaBb" /> + <Policy><Descriptor Staticness="Instance" AccessRightKinds="Private" Description="Instance fields (private)"><ElementKinds><Kind Name="FIELD" /><Kind Name="READONLY_FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="_" Suffix="" Style="aaBb" /></Policy> + <Policy><Descriptor Staticness="Static" AccessRightKinds="Private" Description="Static fields (private)"><ElementKinds><Kind Name="FIELD" /></ElementKinds></Descriptor><Policy Inspect="True" Prefix="_" Suffix="" Style="aaBb" /></Policy> True True True + True True True + True True - Replace argument null check using throw expression with Guard clause + Replace argument null check using throw expression with ArgumentNullException.ThrowIfNull True True False @@ -597,13 +615,12 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$, $NAME$); True CSHARP False - Replace argument null check with Guard clause - JsonApiDotNetCore.ArgumentGuard.NotNull($argument$, nameof($argument$)); + System.ArgumentNullException.ThrowIfNull($argument$); $left$ = $right$; $left$ = $right$ ?? throw new ArgumentNullException(nameof($argument$)); WARNING True - Replace classic argument null check with Guard clause + Replace argument == null check with ArgumentNullException.ThrowIfNull True True False @@ -612,8 +629,7 @@ $left$ = $right$; True CSHARP False - Replace argument null check with Guard clause - JsonApiDotNetCore.ArgumentGuard.NotNull($argument$, nameof($argument$)); + System.ArgumentNullException.ThrowIfNull($argument$); if ($argument$ == null) throw new ArgumentNullException(nameof($argument$)); WARNING True @@ -625,25 +641,44 @@ $left$ = $right$; True CSHARP False - Replace collection null/empty check with extension method $collection$.IsNullOrEmpty() $collection$ == null || !$collection$.Any() WARNING + True + Replace argument is null check with ArgumentNullException.ThrowIfNull + True + True + False + + IdentifierPlaceholder + True + CSHARP + False + System.ArgumentNullException.ThrowIfNull($argument$); + if ($argument$ is null) throw new ArgumentNullException(nameof($argument$)); + WARNING True + True True True True True True + True True True True + True True + True True + True + True True True True True + True True True diff --git a/LICENSE b/LICENSE index 9bab1d270d..c49362c460 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,5 @@ Copyright (c) 2017 Jared Nance +Copyright (c) 2020 Bart Koelman MIT License diff --git a/PackageReadme.md b/PackageReadme.md new file mode 100644 index 0000000000..5bb1ac5342 --- /dev/null +++ b/PackageReadme.md @@ -0,0 +1,5 @@ +A framework for building [JSON:API](https://jsonapi.org/) compliant REST APIs using ASP.NET Core and Entity Framework Core. Includes support for the [Atomic Operations](https://jsonapi.org/ext/atomic/) extension. + +The ultimate goal of this library is to eliminate as much boilerplate as possible by offering out-of-the-box features, such as sorting, filtering, pagination, sparse fieldset selection, and side-loading related resources. You just need to focus on defining the resources and implementing your custom business logic. This library has been designed around dependency injection, making extensibility incredibly easy. + +For more information, visit [www.jsonapi.net](https://www.jsonapi.net/). diff --git a/README.md b/README.md index e2291f080e..5ab49fae35 100644 --- a/README.md +++ b/README.md @@ -1,108 +1,265 @@ -

- -

+ # 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/) +[![GitHub License](https://img.shields.io/github/license/json-api-dotnet/JsonApiDotNetCore)](LICENSE) [![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) -[![FIRST-TIMERS](https://img.shields.io/badge/first--timers--only-friendly-blue.svg)](http://www.firsttimersonly.com/) - -The ultimate goal of this library is to eliminate as much boilerplate as possible by offering out-of-the-box features such as sorting, filtering and pagination. You just need to focus on defining the resources and implementing your custom business logic. This library has been designed around dependency injection, making extensibility incredibly easy. - -## Getting Started - -These are some steps you can take to help you understand what this project is and how you can use it: +[![FIRST-TIMERS](https://img.shields.io/badge/first--timers--only-friendly-blue.svg)](https://www.firsttimersonly.com/) + +A framework for building [JSON:API](https://jsonapi.org/) compliant REST APIs using ASP.NET Core and Entity Framework Core. Includes support for the [Atomic Operations](https://jsonapi.org/ext/atomic/) extension. + +The ultimate goal of this library is to eliminate as much boilerplate as possible by offering out-of-the-box features, such as sorting, filtering, pagination, sparse fieldset selection, and side-loading related resources. You just need to focus on defining the resources and implementing your custom business logic. This library has been designed around dependency injection, making extensibility incredibly easy. + +> [!NOTE] +> OpenAPI support is now [available](https://www.jsonapi.net/usage/openapi.html), currently in preview. Give it a try! + +## Getting started + +The following steps describe how to create a JSON:API project. + +1. Create a new ASP.NET Core Web API project: + + ```bash + dotnet new webapi --no-openapi --use-controllers --name ExampleJsonApi + cd ExampleJsonApi + ``` + +1. Install the JsonApiDotNetCore package, along with your preferred Entity Framework Core provider: + + ```bash + dotnet add package JsonApiDotNetCore + dotnet add package Microsoft.EntityFrameworkCore.Sqlite + ``` + +1. Declare your entities, annotated with JsonApiDotNetCore attributes: + + ```c# + [Resource] + public class Person : Identifiable + { + [Attr] public string? FirstName { get; set; } + [Attr] public string LastName { get; set; } = null!; + [HasMany] public ISet Children { get; set; } = new HashSet(); + } + ``` + +1. Define your `DbContext`, seeding the database with sample data: + + ```c# + public class AppDbContext(DbContextOptions options) : DbContext(options) + { + public DbSet People => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder builder) + { + builder.UseSqlite("Data Source=SampleDb.db"); + builder.UseAsyncSeeding(async (dbContext, _, cancellationToken) => + { + dbContext.Set().Add(new Person + { + FirstName = "John", + LastName = "Doe", + Children = + { + new Person + { + FirstName = "Baby", + LastName = "Doe" + } + } + }); + await dbContext.SaveChangesAsync(cancellationToken); + }); + } + } + ``` + +1. Configure Entity Framework Core and JsonApiDotNetCore in `Program.cs`: + + ```c# + var builder = WebApplication.CreateBuilder(args); + builder.Services.AddDbContext(); + builder.Services.AddJsonApi(options => + { + options.UseRelativeLinks = true; + options.IncludeTotalResourceCount = true; + }); + + var app = builder.Build(); + app.UseRouting(); + app.UseJsonApi(); + app.MapControllers(); + await CreateDatabaseAsync(app.Services); + app.Run(); + + static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) + { + await using var scope = serviceProvider.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.EnsureDeletedAsync(); + await dbContext.Database.EnsureCreatedAsync(); + } + ``` + +1. Start your API + + ```bash + dotnet run + ``` + +1. Send a GET request to retrieve data: + + ```bash + GET http://localhost:5000/people?filter=equals(firstName,'John')&include=children HTTP/1.1 + ``` + +
+ Expand to view the JSON response + + ```json + { + "links": { + "self": "/people?filter=equals(firstName,%27John%27)&include=children", + "first": "/people?filter=equals(firstName,%27John%27)&include=children", + "last": "/people?filter=equals(firstName,%27John%27)&include=children" + }, + "data": [ + { + "type": "people", + "id": "1", + "attributes": { + "firstName": "John", + "lastName": "Doe" + }, + "relationships": { + "children": { + "links": { + "self": "/people/1/relationships/children", + "related": "/people/1/children" + }, + "data": [ + { + "type": "people", + "id": "2" + } + ] + } + }, + "links": { + "self": "/people/1" + } + } + ], + "included": [ + { + "type": "people", + "id": "2", + "attributes": { + "firstName": "Baby", + "lastName": "Doe" + }, + "relationships": { + "children": { + "links": { + "self": "/people/2/relationships/children", + "related": "/people/2/children" + } + } + }, + "links": { + "self": "/people/2" + } + } + ], + "meta": { + "total": 1 + } + } + ``` + +
+ +## Learn more + +The following links explain what this project provides, why it exists, and how you can use it. ### About + - [What is JSON:API and why should I use it?](https://nordicapis.com/the-benefits-of-using-json-api/) (blog, 2017) - [Pragmatic JSON:API Design](https://www.youtube.com/watch?v=3jBJOga4e2Y) (video, 2017) - [JSON:API and JsonApiDotNetCore](https://www.youtube.com/watch?v=79Oq0HOxyeI) (video, 2021) - [JsonApiDotNetCore Release 4.0](https://dev.to/wunki/getting-started-5dkl) (blog, 2020) -- [JSON:API, .Net Core, EmberJS](https://youtu.be/KAMuo6K7VcE) (video, 2017) +- [JSON:API, ASP.NET Core, EmberJS](https://youtu.be/KAMuo6K7VcE) (video, 2017) - [Embercasts: Full Stack Ember with ASP.NET Core](https://www.embercasts.com/course/full-stack-ember-with-dotnet/watch/whats-in-this-course-cs) (paid course, 2017) ### Official documentation -- [The JSON:API specification](https://jsonapi.org/format/1.1/) -- [JsonApiDotNetCore website](https://www.jsonapi.net/) -- [Roadmap](ROADMAP.md) - -## Related Projects - -- [Performance Reports](https://github.com/json-api-dotnet/PerformanceReports) -- [JsonApiDotNetCore.MongoDb](https://github.com/json-api-dotnet/JsonApiDotNetCore.MongoDb) -- [JsonApiDotNetCore.Marten](https://github.com/wayne-o/JsonApiDotNetCore.Marten) -- [Todo List App](https://github.com/json-api-dotnet/TodoListExample) - -## Examples - -See the [examples](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/src/Examples) directory for up-to-date sample applications. There is also a [Todo List App](https://github.com/json-api-dotnet/TodoListExample) that includes a JsonApiDotNetCore API and an EmberJs client. - -## Installation and Usage - -See [our documentation](https://www.jsonapi.net/) for detailed usage. - -### Models - -```c# -#nullable enable - -[Resource] -public class Article : Identifiable -{ - [Attr] - public string Name { get; set; } = null!; -} -``` -### Middleware +- [JsonApiDotNetCore documentation](https://www.jsonapi.net/) +- [The JSON:API specification](https://jsonapi.org/format/) +- [JsonApiDotNetCore roadmap](ROADMAP.md) -```c# -// Program.cs +### Samples -builder.Services.AddJsonApi(); +- The [examples](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/src/Examples) directory provides ready-to-run sample API projects, which are documented [here](https://www.jsonapi.net/request-examples/index.html). +- The [integration tests](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests) directory covers many advanced use cases, which are documented [here](https://www.jsonapi.net/usage/advanced/index.html). + This includes topics such as batching, multi-tenancy, authorization, soft-deletion, obfuscated IDs, resource inheritance, alternate routing, custom metadata, error handling and logging. +- The [Ember.js Todo List App](https://github.com/json-api-dotnet/TodoListExample) showcases a JsonApiDotNetCore API and an Ember.js client with token authentication. -// ... +### Related projects -app.UseRouting(); -app.UseJsonApi(); -app.MapControllers(); -``` +- [JsonApiDotNetCore.MongoDb](https://github.com/json-api-dotnet/JsonApiDotNetCore.MongoDb) +- [Ember.js Todo List App](https://github.com/json-api-dotnet/TodoListExample) +- [Performance Reports](https://github.com/json-api-dotnet/PerformanceReports) ## Compatibility The following chart should help you pick the best version, based on your environment. See also our [versioning policy](./VERSIONING_POLICY.md). -| JsonApiDotNetCore | Status | .NET | Entity Framework Core | -| ----------------- | ----------- | -------- | --------------------- | -| 3.x | Stable | Core 2.x | 2.x | -| 4.x | Stable | Core 3.1 | 3.1 | -| | | Core 3.1 | 5 | -| | | 5 | 5 | -| | | 6 | 5 | -| v5.x | Stable | 6 | 6 | +| JsonApiDotNetCore | Status | .NET | Entity Framework Core | +| ----------------- | ------------ | -------- | --------------------- | +| master | Preview | 9 | 9 | +| | | 8 | 8, 9 | +| 5.7.0+ | Stable | 9 | 9 | +| | | 8 | 8, 9 | +| 5.5.0-5.6.0 | Stable | 9 | 9 | +| | | 8 | 8, 9 | +| | | 7 | 7 | +| | | 6 | 6, 7 | +| 5.0.3-5.4.0 | Stable | 7 | 7 | +| | | 6 | 6, 7 | +| 5.0.0-5.0.2 | Stable | 6 | 6 | +| 4.x | Stable | 6 | 5 | +| | | 5 | 5 | +| | | Core 3.1 | 3.1, 5 | +| 3.x | Stable | Core 2.x | 2.x | -## Contributing +## Trying out the latest build -Have a question, found a bug or want to submit code changes? See our [contributing guidelines](./.github/CONTRIBUTING.md). +After each commit to the master branch, a new pre-release NuGet package is automatically published to [feedz.io](https://feedz.io/docs/package-types/nuget). +To try it out, follow the steps below: -## Trying out the latest build +1. Create a `nuget.config` file in the same directory as your .sln file, with the following contents: + ```xml + + + + + + + + ``` -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: +1. In your IDE, browse the list of packages from the `json-api-dotnet` feed. Make sure pre-release packages are included in the list. -* 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` +## Contributing + +Have a question, found a bug or want to submit code changes? See our [contributing guidelines](./.github/CONTRIBUTING.md). -## Development +## Build from source To build the code from this repository locally, run: @@ -110,7 +267,7 @@ To build the code from this repository locally, run: dotnet build ``` -Running tests locally requires access to a PostgreSQL database. If you have docker installed, this can be propped up via: +Running tests locally requires access to a PostgreSQL database. If you have docker installed, this can be started via: ```bash pwsh run-docker-postgres.ps1 @@ -122,8 +279,17 @@ 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 ``` + +## Sponsors + +We are very grateful to the sponsors below, who have provided us with a no-cost license for their tools. + +JetBrains Logo   +Araxis Logo + +Do you like this project? Consider to [sponsor](https://github.com/sponsors/json-api-dotnet), or just reward us by giving our repository a star. diff --git a/ROADMAP.md b/ROADMAP.md index 270ae294e6..d73797824f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,14 +2,18 @@ This document provides an overview of the direction this project is heading and lists what we intend to work on in the near future. -> Disclaimer: This is an open source project. The available time of our contributors varies and therefore we do not plan release dates. This document expresses our current intent, which may change over time. +> Disclaimer: This is an open-source project. The available time of our contributors varies and therefore we do not plan release dates. This document expresses our current intent, which may change over time. -We have interest in the following topics. It's too soon yet to decide whether they'll make it into v5.x or in a later major version. +We have an interest in the following topics. It's too soon yet to decide whether they'll make it into v5.x or in a later major version. +- Provide additional OpenAPI support [#1046](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1046) +- Query strings on JSON-mapped columns [#1439](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1439) +- Improved resource inheritance [#1642](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1642) +- Improved SQL Server support [#1118](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1118) +- Use incremental source generator [#1447](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1447) - Optimistic concurrency [#1119](https://github.com/json-api-dotnet/JsonApiDotNetCore/pull/1119) -- OpenAPI (Swagger) [#1046](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1046) -- Fluent API [#776](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/776) - Idempotency [#1132](https://github.com/json-api-dotnet/JsonApiDotNetCore/pull/1132) +- Fluent API [#776](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/776) ## Feedback @@ -19,3 +23,5 @@ Please give us feedback that will give us insight on the following points: * Existing features that are missing some capability or otherwise don't work well enough. * Missing features that should be added to the product. * Design choices for a feature that is currently in-progress. + +Please consider to [sponsor the project](https://github.com/sponsors/json-api-dotnet). diff --git a/VERSIONING_POLICY.md b/VERSIONING_POLICY.md index d44770cfcc..a32ee43d7d 100644 --- a/VERSIONING_POLICY.md +++ b/VERSIONING_POLICY.md @@ -1,8 +1,8 @@ # JsonApiDotNetCore versioning policy -Basically, we strive to adhere to [semantic versioning](https://semver.org/). +Basically, we strive to adhere to [Semantic Versioning](https://semver.org/). -However, we believe that our userbase is still small enough to allow for some flexibility in _minor_ updates, see below. +However, we believe that our user base is still small enough to allow for some flexibility in _minor_ updates, see below. ## Major updates @@ -10,9 +10,9 @@ For breaking changes in _major_ updates, we intend to list the ones that may aff ## Minor updates -We **WILL NOT** introduce breaking changes in _minor_ updates on our common extensibility points such as controllers, resource services, resource repositories, resource definitions, and `Identifiable` as well as common annotations such as `[Attr]`, `[HasOne]`, `[HasMany]`, and `[HasManyThrough]`. The same applies to the URL routes, JSON structure of request/response bodies, and query string syntax. +We **WILL NOT** introduce breaking changes in _minor_ updates on our common extensibility points, such as controllers, resource services, resource repositories, resource definitions, and `Identifiable`, as well as common annotations, such as `[Attr]`, `[HasOne]`, and `[HasMany]`. The same applies to the URL routes, JSON structure of request and response bodies, and query string syntax. -In previous versions of JsonApiDotNetCore, almost everything was public. While that makes customizations very easy for users, it kinda puts us in a corner: nearly every change would require a new major version. Therefore we try to balance between adding new features to the next _minor_ version or postpone them to the next _major_ version. This means we **CAREFULLY CONSIDER** if we can prevent breaking changes in _minor_ updates to signatures of "pubternal" types (public types in `Internal` namespaces), as well as exposed types of which we believe users are unlikely to have taken a direct dependency on. One example would be to inject an additional dependency in the constructor of a not-so-well-known class, such as `IncludedResourceObjectBuilder`. In the unlikely case that a user has taken a dependency on this class, the compiler error message is clear and the fix is obvious and easy. We may introduce binary breaking changes (such as adding an optional constructor parameter to a custom exception type), which requires users to recompile their existing code. +In previous versions of JsonApiDotNetCore, almost everything was public. While that makes customizations very easy for users, it kinda puts us in a corner: nearly every change would require a new major version. Therefore, we try to balance between adding new features to the next _minor_ version or postponing them to the next _major_ version. This means we **CAREFULLY CONSIDER** if we can prevent breaking changes in _minor_ updates to signatures of "pubternal" types (public types in `Internal` namespaces), as well as exposed types of which we believe users are unlikely to have taken a direct dependency on. One example would be to inject an additional dependency in the constructor of a not-so-well-known class, such as `OperationsProcessor`. In the unlikely case that a user has taken a dependency on this class, the compiler error message is clear, and the fix is obvious and easy. We may introduce binary breaking changes (such as adding an optional constructor parameter to a custom exception type), which requires users to recompile their existing code. Our goal is to try to minimize such breaks and require only a recompile of existing API projects. This also means that we'll need to publish an updated release for [JsonApiDotNetCore.MongoDb](https://github.com/json-api-dotnet/JsonApiDotNetCore.MongoDb) when this happens. @@ -20,4 +20,4 @@ We may also correct error messages in _minor_ updates. ## Backports -When users report that they are unable to upgrade as a result of breaking changes in a _minor_ version, we're willing to consider backporting fixes they need to an earlier _minor_ version. +When users report that they're unable to upgrade as a result of breaking changes in a _minor_ version, we're willing to consider backporting fixes they need to an earlier _minor_ version. diff --git a/WarningSeverities.DotSettings b/WarningSeverities.DotSettings index f4a9ae32e8..5b64971520 100644 --- a/WarningSeverities.DotSettings +++ b/WarningSeverities.DotSettings @@ -1,4 +1,5 @@  + WARNING WARNING WARNING WARNING @@ -13,6 +14,7 @@ WARNING WARNING WARNING + WARNING WARNING WARNING WARNING @@ -68,8 +70,10 @@ WARNING WARNING WARNING + WARNING WARNING WARNING + WARNING WARNING WARNING WARNING @@ -82,12 +86,12 @@ WARNING WARNING WARNING + WARNING WARNING WARNING WARNING WARNING WARNING - WARNING WARNING WARNING WARNING @@ -98,8 +102,13 @@ WARNING WARNING WARNING + WARNING WARNING WARNING + WARNING + WARNING + WARNING + WARNING WARNING WARNING WARNING @@ -110,12 +119,16 @@ WARNING WARNING WARNING + WARNING WARNING WARNING WARNING WARNING + WARNING WARNING WARNING + WARNING + WARNING WARNING WARNING WARNING @@ -136,6 +149,7 @@ WARNING WARNING WARNING + WARNING WARNING WARNING WARNING @@ -153,6 +167,8 @@ WARNING WARNING WARNING + WARNING + WARNING WARNING WARNING WARNING @@ -228,6 +244,7 @@ WARNING WARNING WARNING + WARNING WARNING WARNING WARNING @@ -241,14 +258,17 @@ WARNING WARNING WARNING + WARNING WARNING WARNING WARNING WARNING + WARNING WARNING WARNING WARNING WARNING WARNING WARNING + WARNING \ No newline at end of file diff --git a/appveyor.yml b/appveyor.yml index 61feec2ab8..1ad1455837 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,112 +1,12 @@ -image: - - Ubuntu - - Visual Studio 2022 +image: Visual Studio 2022 version: '{build}' -stack: postgresql 13.4 - -environment: - PGUSER: postgres - PGPASSWORD: Password12! - GIT_ACCESS_TOKEN: - secure: vw2jhp7V38fTOqphzFgnXtLwHoHRW2zM2K5RJgDAnmkoaIKT6jXLDIfkFdyVz9nJ - 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: - - postgresql13 - # 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 - } - choco install docfx -y - if ($lastexitcode -ne 0) { - throw "docfx install failed with exit code $lastexitcode." - } - after_build: - - pwsh: | - CD ./docs - & ./generate-examples.ps1 - & docfx docfx.json - if ($lastexitcode -ne 0) { - throw "docfx build 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/ - 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: S9fkLwmhi7w+DGouXYqYq/1PGocnYo8UBUKwv+BGpWHnzE6yHZEYth3j/XJ9Ydsa - on: - branch: master - appveyor_repo_tag: true - - provider: NuGet - skip_symbols: false - api_key: - secure: S9fkLwmhi7w+DGouXYqYq/1PGocnYo8UBUKwv+BGpWHnzE6yHZEYth3j/XJ9Ydsa - on: - branch: /release\/.+/ - appveyor_repo_tag: true - -build_script: -- pwsh: | - Write-Output ".NET version:" - dotnet --version - - Write-Output "PostgreSQL version:" - if ($IsWindows) { - . "${env:ProgramFiles}\PostgreSQL\13\bin\psql" --version - } - else { - psql --version - } - - .\Build.ps1 - +build: off test: off +deploy: off diff --git a/benchmarks/Benchmarks.csproj b/benchmarks/Benchmarks.csproj index 4bde435c15..90d53461d2 100644 --- a/benchmarks/Benchmarks.csproj +++ b/benchmarks/Benchmarks.csproj @@ -1,15 +1,20 @@ Exe - $(TargetFrameworkName) + net9.0 + true + + - - + + + + diff --git a/benchmarks/Deserialization/DeserializationBenchmarkBase.cs b/benchmarks/Deserialization/DeserializationBenchmarkBase.cs index bbf746d1a8..4febabba1a 100644 --- a/benchmarks/Deserialization/DeserializationBenchmarkBase.cs +++ b/benchmarks/Deserialization/DeserializationBenchmarkBase.cs @@ -11,10 +11,12 @@ namespace Benchmarks.Deserialization; -public abstract class DeserializationBenchmarkBase +public abstract class DeserializationBenchmarkBase : IDisposable { - protected readonly JsonSerializerOptions SerializerReadOptions; - protected readonly DocumentAdapter DocumentAdapter; + private readonly ServiceContainer _serviceProvider = new(); + + protected JsonSerializerOptions SerializerReadOptions { get; } + protected DocumentAdapter DocumentAdapter { get; } protected DeserializationBenchmarkBase() { @@ -23,12 +25,11 @@ protected DeserializationBenchmarkBase() options.SerializerOptions.Converters.Add(new ResourceObjectConverter(resourceGraph)); SerializerReadOptions = ((IJsonApiOptions)options).SerializerReadOptions; - var serviceContainer = new ServiceContainer(); - var resourceFactory = new ResourceFactory(serviceContainer); - var resourceDefinitionAccessor = new ResourceDefinitionAccessor(resourceGraph, serviceContainer); + var resourceFactory = new ResourceFactory(_serviceProvider); + var resourceDefinitionAccessor = new ResourceDefinitionAccessor(resourceGraph, _serviceProvider); - serviceContainer.AddService(typeof(IResourceDefinitionAccessor), resourceDefinitionAccessor); - serviceContainer.AddService(typeof(IResourceDefinition), new JsonApiResourceDefinition(resourceGraph)); + _serviceProvider.AddService(typeof(IResourceDefinitionAccessor), resourceDefinitionAccessor); + _serviceProvider.AddService(typeof(IResourceDefinition), new JsonApiResourceDefinition(resourceGraph)); // ReSharper disable once VirtualMemberCallInConstructor JsonApiRequest request = CreateJsonApiRequest(resourceGraph); @@ -53,6 +54,22 @@ protected DeserializationBenchmarkBase() protected abstract JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph); + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + +#pragma warning disable CA1063 // Implement IDisposable Correctly + private void Dispose(bool disposing) +#pragma warning restore CA1063 // Implement IDisposable Correctly + { + if (disposing) + { + _serviceProvider.Dispose(); + } + } + [UsedImplicitly(ImplicitUseTargetFlags.Members)] public sealed class IncomingResource : Identifiable { diff --git a/benchmarks/Deserialization/OperationsDeserializationBenchmarks.cs b/benchmarks/Deserialization/OperationsDeserializationBenchmarks.cs index d28684e27b..99adce73cb 100644 --- a/benchmarks/Deserialization/OperationsDeserializationBenchmarks.cs +++ b/benchmarks/Deserialization/OperationsDeserializationBenchmarks.cs @@ -7,6 +7,7 @@ namespace Benchmarks.Deserialization; [MarkdownExporter] +[MemoryDiagnoser] // ReSharper disable once ClassCanBeSealed.Global public class OperationsDeserializationBenchmarks : DeserializationBenchmarkBase { diff --git a/benchmarks/Deserialization/ResourceDeserializationBenchmarks.cs b/benchmarks/Deserialization/ResourceDeserializationBenchmarks.cs index 23a6205bf5..e503a329bb 100644 --- a/benchmarks/Deserialization/ResourceDeserializationBenchmarks.cs +++ b/benchmarks/Deserialization/ResourceDeserializationBenchmarks.cs @@ -7,6 +7,7 @@ namespace Benchmarks.Deserialization; [MarkdownExporter] +[MemoryDiagnoser] // ReSharper disable once ClassCanBeSealed.Global public class ResourceDeserializationBenchmarks : DeserializationBenchmarkBase { diff --git a/benchmarks/Program.cs b/benchmarks/Program.cs index 818b9ab5e5..04d5fa1eaa 100644 --- a/benchmarks/Program.cs +++ b/benchmarks/Program.cs @@ -3,21 +3,12 @@ using Benchmarks.QueryString; using Benchmarks.Serialization; -namespace Benchmarks; +var switcher = new BenchmarkSwitcher([ + typeof(ResourceDeserializationBenchmarks), + typeof(OperationsDeserializationBenchmarks), + typeof(ResourceSerializationBenchmarks), + typeof(OperationsSerializationBenchmarks), + typeof(QueryStringParserBenchmarks) +]); -internal static class Program -{ - private static void Main(string[] args) - { - var switcher = new BenchmarkSwitcher(new[] - { - typeof(ResourceDeserializationBenchmarks), - typeof(OperationsDeserializationBenchmarks), - typeof(ResourceSerializationBenchmarks), - typeof(OperationsSerializationBenchmarks), - typeof(QueryStringParserBenchmarks) - }); - - switcher.Run(args); - } -} +switcher.Run(args); diff --git a/benchmarks/QueryString/QueryStringParserBenchmarks.cs b/benchmarks/QueryString/QueryStringParserBenchmarks.cs index efa4f12659..5e5a65ed9f 100644 --- a/benchmarks/QueryString/QueryStringParserBenchmarks.cs +++ b/benchmarks/QueryString/QueryStringParserBenchmarks.cs @@ -1,13 +1,11 @@ using System.ComponentModel.Design; using BenchmarkDotNet.Attributes; -using JsonApiDotNetCore; +using Benchmarks.Tools; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries.Parsing; using JsonApiDotNetCore.QueryStrings; -using JsonApiDotNetCore.QueryStrings.Internal; using JsonApiDotNetCore.Resources; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Logging.Abstractions; namespace Benchmarks.QueryString; @@ -16,8 +14,9 @@ namespace Benchmarks.QueryString; [MarkdownExporter] [SimpleJob(3, 10, 20)] [MemoryDiagnoser] -public class QueryStringParserBenchmarks +public class QueryStringParserBenchmarks : IDisposable { + private readonly ServiceContainer _serviceProvider = new(); private readonly FakeRequestQueryStringAccessor _queryStringAccessor = new(); private readonly QueryStringReader _queryStringReader; @@ -32,20 +31,38 @@ public QueryStringParserBenchmarks() var request = new JsonApiRequest { - PrimaryResourceType = resourceGraph.GetResourceType(typeof(QueryableResource)), + PrimaryResourceType = resourceGraph.GetResourceType(), IsCollection = true }; - var resourceFactory = new ResourceFactory(new ServiceContainer()); + var resourceFactory = new ResourceFactory(_serviceProvider); - var includeReader = new IncludeQueryStringParameterReader(request, resourceGraph, options); - var filterReader = new FilterQueryStringParameterReader(request, resourceGraph, resourceFactory, options); - var sortReader = new SortQueryStringParameterReader(request, resourceGraph); - var sparseFieldSetReader = new SparseFieldSetQueryStringParameterReader(request, resourceGraph); - var paginationReader = new PaginationQueryStringParameterReader(request, resourceGraph, options); + var includeParser = new IncludeParser(options); + var includeReader = new IncludeQueryStringParameterReader(includeParser, request, resourceGraph); - IQueryStringParameterReader[] readers = ArrayFactory.Create(includeReader, filterReader, sortReader, - sparseFieldSetReader, paginationReader); + var filterScopeParser = new QueryStringParameterScopeParser(); + var filterValueParser = new FilterParser(resourceFactory); + var filterReader = new FilterQueryStringParameterReader(filterScopeParser, filterValueParser, request, resourceGraph, options); + + var sortScopeParser = new QueryStringParameterScopeParser(); + var sortValueParser = new SortParser(); + var sortReader = new SortQueryStringParameterReader(sortScopeParser, sortValueParser, request, resourceGraph); + + var sparseFieldSetScopeParser = new SparseFieldTypeParser(resourceGraph); + var sparseFieldSetValueParser = new SparseFieldSetParser(); + var sparseFieldSetReader = new SparseFieldSetQueryStringParameterReader(sparseFieldSetScopeParser, sparseFieldSetValueParser, request, resourceGraph); + + var paginationParser = new PaginationParser(); + var paginationReader = new PaginationQueryStringParameterReader(paginationParser, request, resourceGraph, options); + + IQueryStringParameterReader[] readers = + [ + includeReader, + filterReader, + sortReader, + sparseFieldSetReader, + paginationReader + ]; _queryStringReader = new QueryStringReader(options, _queryStringAccessor, readers, NullLoggerFactory.Instance); } @@ -71,31 +88,25 @@ public void DescendingSort() [Benchmark] public void ComplexQuery() { - Run(100, () => - { - const string queryString = - "?filter[alt-attr-name]=abc,eq:abc&sort=-alt-attr-name&include=child&page[size]=1&fields[alt-resource-name]=alt-attr-name"; + const string queryString = "?filter[alt-attr-name]=abc,eq:abc&sort=-alt-attr-name&include=child&page[size]=1&fields[alt-resource-name]=alt-attr-name"; - _queryStringAccessor.SetQueryString(queryString); - _queryStringReader.ReadAll(null); - }); + _queryStringAccessor.SetQueryString(queryString); + _queryStringReader.ReadAll(null); } - private void Run(int iterations, Action action) + public void Dispose() { - for (int index = 0; index < iterations; index++) - { - action(); - } + Dispose(true); + GC.SuppressFinalize(this); } - private sealed class FakeRequestQueryStringAccessor : IRequestQueryStringAccessor +#pragma warning disable CA1063 // Implement IDisposable Correctly + private void Dispose(bool disposing) +#pragma warning restore CA1063 // Implement IDisposable Correctly { - public IQueryCollection Query { get; private set; } = new QueryCollection(); - - public void SetQueryString(string queryString) + if (disposing) { - Query = new QueryCollection(QueryHelpers.ParseQuery(queryString)); + _serviceProvider.Dispose(); } } } diff --git a/benchmarks/Serialization/OperationsSerializationBenchmarks.cs b/benchmarks/Serialization/OperationsSerializationBenchmarks.cs index 7076ca5cb8..8c4a00b6da 100644 --- a/benchmarks/Serialization/OperationsSerializationBenchmarks.cs +++ b/benchmarks/Serialization/OperationsSerializationBenchmarks.cs @@ -2,17 +2,18 @@ using BenchmarkDotNet.Attributes; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Queries.Internal; +using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; namespace Benchmarks.Serialization; [MarkdownExporter] +[MemoryDiagnoser] // ReSharper disable once ClassCanBeSealed.Global public class OperationsSerializationBenchmarks : SerializationBenchmarkBase { - private readonly IEnumerable _responseOperations; + private readonly List _responseOperations; public OperationsSerializationBenchmarks() { @@ -22,7 +23,7 @@ public OperationsSerializationBenchmarks() _responseOperations = CreateResponseOperations(request); } - private static IEnumerable CreateResponseOperations(IJsonApiRequest request) + private static List CreateResponseOperations(IJsonApiRequest request) { var resource1 = new OutgoingResource { @@ -101,14 +102,14 @@ private static IEnumerable CreateResponseOperations(IJsonApi var targetedFields = new TargetedFields(); - return new List - { - new(resource1, targetedFields, request), - new(resource2, targetedFields, request), - new(resource3, targetedFields, request), - new(resource4, targetedFields, request), - new(resource5, targetedFields, request) - }; + return + [ + new OperationContainer(resource1, targetedFields, request), + new OperationContainer(resource2, targetedFields, request), + new OperationContainer(resource3, targetedFields, request), + new OperationContainer(resource4, targetedFields, request), + new OperationContainer(resource5, targetedFields, request) + ]; } [Benchmark] @@ -129,6 +130,6 @@ protected override JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGr protected override IEvaluatedIncludeCache CreateEvaluatedIncludeCache(IResourceGraph resourceGraph) { - return new EvaluatedIncludeCache(); + return new EvaluatedIncludeCache(Array.Empty()); } } diff --git a/benchmarks/Serialization/ResourceSerializationBenchmarks.cs b/benchmarks/Serialization/ResourceSerializationBenchmarks.cs index 12f5c2e788..6f979e86b9 100644 --- a/benchmarks/Serialization/ResourceSerializationBenchmarks.cs +++ b/benchmarks/Serialization/ResourceSerializationBenchmarks.cs @@ -1,17 +1,17 @@ using System.Collections.Immutable; using System.Text.Json; using BenchmarkDotNet.Attributes; -using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; namespace Benchmarks.Serialization; [MarkdownExporter] +[MemoryDiagnoser] // ReSharper disable once ClassCanBeSealed.Global public class ResourceSerializationBenchmarks : SerializationBenchmarkBase { @@ -96,12 +96,17 @@ private static OutgoingResource CreateResponseResource() resource1.Single2 = resource2; resource2.Single3 = resource3; - resource3.Multi4 = resource4.AsHashSet(); - resource4.Multi5 = resource5.AsHashSet(); + resource3.Multi4 = ToHashSet(resource4); + resource4.Multi5 = ToHashSet(resource5); return resource1; } + private static HashSet ToHashSet(T element) + { + return [element]; + } + [Benchmark] public string SerializeResourceResponse() { @@ -120,12 +125,12 @@ protected override JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGr protected override IEvaluatedIncludeCache CreateEvaluatedIncludeCache(IResourceGraph resourceGraph) { - ResourceType resourceAType = resourceGraph.GetResourceType(); + ResourceType resourceType = resourceGraph.GetResourceType(); - RelationshipAttribute single2 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Single2)); - RelationshipAttribute single3 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Single3)); - RelationshipAttribute multi4 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Multi4)); - RelationshipAttribute multi5 = resourceAType.GetRelationshipByPropertyName(nameof(OutgoingResource.Multi5)); + RelationshipAttribute single2 = resourceType.GetRelationshipByPropertyName(nameof(OutgoingResource.Single2)); + RelationshipAttribute single3 = resourceType.GetRelationshipByPropertyName(nameof(OutgoingResource.Single3)); + RelationshipAttribute multi4 = resourceType.GetRelationshipByPropertyName(nameof(OutgoingResource.Multi4)); + RelationshipAttribute multi5 = resourceType.GetRelationshipByPropertyName(nameof(OutgoingResource.Multi5)); var include = new IncludeExpression(new HashSet { @@ -141,7 +146,7 @@ protected override IEvaluatedIncludeCache CreateEvaluatedIncludeCache(IResourceG }.ToImmutableHashSet()) }.ToImmutableHashSet()); - var cache = new EvaluatedIncludeCache(); + var cache = new EvaluatedIncludeCache(Array.Empty()); cache.Set(include); return cache; } diff --git a/benchmarks/Serialization/SerializationBenchmarkBase.cs b/benchmarks/Serialization/SerializationBenchmarkBase.cs index e1bcb10843..c8451835cc 100644 --- a/benchmarks/Serialization/SerializationBenchmarkBase.cs +++ b/benchmarks/Serialization/SerializationBenchmarkBase.cs @@ -1,27 +1,22 @@ -using System.Collections.Immutable; using System.Text.Json; using System.Text.Json.Serialization; +using Benchmarks.Tools; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal; -using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCore.Serialization.Response; -using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging.Abstractions; namespace Benchmarks.Serialization; public abstract class SerializationBenchmarkBase { - protected readonly JsonSerializerOptions SerializerWriteOptions; - protected readonly IResponseModelAdapter ResponseModelAdapter; - protected readonly IResourceGraph ResourceGraph; + protected JsonSerializerOptions SerializerWriteOptions { get; } + protected IResponseModelAdapter ResponseModelAdapter { get; } + protected IResourceGraph ResourceGraph { get; } protected SerializationBenchmarkBase() { @@ -45,9 +40,9 @@ protected SerializationBenchmarkBase() // ReSharper restore VirtualMemberCallInConstructor var linkBuilder = new FakeLinkBuilder(); - var metaBuilder = new FakeMetaBuilder(); - IQueryConstraintProvider[] constraintProviders = Array.Empty(); - var resourceDefinitionAccessor = new FakeResourceDefinitionAccessor(); + var metaBuilder = new NoMetaBuilder(); + IQueryConstraintProvider[] constraintProviders = []; + var resourceDefinitionAccessor = new NeverResourceDefinitionAccessor(); var sparseFieldSetCache = new SparseFieldSetCache(constraintProviders, resourceDefinitionAccessor); var requestQueryStringAccessor = new FakeRequestQueryStringAccessor(); @@ -122,141 +117,4 @@ public sealed class OutgoingResource : Identifiable [HasMany] public ISet Multi5 { get; set; } = null!; } - - private sealed class FakeResourceDefinitionAccessor : IResourceDefinitionAccessor - { - public IImmutableSet OnApplyIncludes(ResourceType resourceType, IImmutableSet existingIncludes) - { - return existingIncludes; - } - - public FilterExpression? OnApplyFilter(ResourceType resourceType, FilterExpression? existingFilter) - { - return existingFilter; - } - - public SortExpression? OnApplySort(ResourceType resourceType, SortExpression? existingSort) - { - return existingSort; - } - - public PaginationExpression? OnApplyPagination(ResourceType resourceType, PaginationExpression? existingPagination) - { - return existingPagination; - } - - public SparseFieldSetExpression? OnApplySparseFieldSet(ResourceType resourceType, SparseFieldSetExpression? existingSparseFieldSet) - { - return existingSparseFieldSet; - } - - public object? GetQueryableHandlerForQueryStringParameter(Type resourceClrType, string parameterName) - { - return null; - } - - public IDictionary? GetMeta(ResourceType resourceType, IIdentifiable resourceInstance) - { - return null; - } - - public Task OnPrepareWriteAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - return Task.CompletedTask; - } - - public Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, - IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - return Task.FromResult(rightResourceId); - } - - public Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - WriteOperationKind writeOperation, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - return Task.CompletedTask; - } - - public Task OnAddToRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - return Task.CompletedTask; - } - - public Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - return Task.CompletedTask; - } - - public Task OnWritingAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - return Task.CompletedTask; - } - - public Task OnWriteSucceededAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - return Task.CompletedTask; - } - - public void OnDeserialize(IIdentifiable resource) - { - } - - public void OnSerialize(IIdentifiable resource) - { - } - } - - private sealed class FakeLinkBuilder : ILinkBuilder - { - public TopLevelLinks GetTopLevelLinks() - { - return new TopLevelLinks - { - Self = "TopLevel:Self" - }; - } - - public ResourceLinks GetResourceLinks(ResourceType resourceType, IIdentifiable resource) - { - return new ResourceLinks - { - Self = "Resource:Self" - }; - } - - public RelationshipLinks GetRelationshipLinks(RelationshipAttribute relationship, IIdentifiable leftResource) - { - return new RelationshipLinks - { - Self = "Relationship:Self", - Related = "Relationship:Related" - }; - } - } - - private sealed class FakeMetaBuilder : IMetaBuilder - { - public void Add(IDictionary values) - { - } - - public IDictionary? Build() - { - return null; - } - } - - private sealed class FakeRequestQueryStringAccessor : IRequestQueryStringAccessor - { - public IQueryCollection Query { get; } = new QueryCollection(0); - } } diff --git a/benchmarks/Tools/FakeLinkBuilder.cs b/benchmarks/Tools/FakeLinkBuilder.cs new file mode 100644 index 0000000000..3468237507 --- /dev/null +++ b/benchmarks/Tools/FakeLinkBuilder.cs @@ -0,0 +1,39 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Serialization.Response; +using Microsoft.AspNetCore.Http; + +namespace Benchmarks.Tools; + +/// +/// Renders hard-coded fake links, without depending on . +/// +internal sealed class FakeLinkBuilder : ILinkBuilder +{ + public TopLevelLinks GetTopLevelLinks() + { + return new TopLevelLinks + { + Self = "TopLevel:Self" + }; + } + + public ResourceLinks GetResourceLinks(ResourceType resourceType, IIdentifiable resource) + { + return new ResourceLinks + { + Self = "Resource:Self" + }; + } + + public RelationshipLinks GetRelationshipLinks(RelationshipAttribute relationship, IIdentifiable leftResource) + { + return new RelationshipLinks + { + Self = "Relationship:Self", + Related = "Relationship:Related" + }; + } +} diff --git a/benchmarks/Tools/FakeRequestQueryStringAccessor.cs b/benchmarks/Tools/FakeRequestQueryStringAccessor.cs new file mode 100644 index 0000000000..8b2b5540a1 --- /dev/null +++ b/benchmarks/Tools/FakeRequestQueryStringAccessor.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.QueryStrings; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.WebUtilities; + +namespace Benchmarks.Tools; + +/// +/// Enables to inject a query string, instead of obtaining it from . +/// +internal sealed class FakeRequestQueryStringAccessor : IRequestQueryStringAccessor +{ + public IQueryCollection Query { get; private set; } = new QueryCollection(); + + public void SetQueryString(string queryString) + { + Query = new QueryCollection(QueryHelpers.ParseQuery(queryString)); + } +} diff --git a/benchmarks/Tools/NeverResourceDefinitionAccessor.cs b/benchmarks/Tools/NeverResourceDefinitionAccessor.cs new file mode 100644 index 0000000000..a6f7ca1789 --- /dev/null +++ b/benchmarks/Tools/NeverResourceDefinitionAccessor.cs @@ -0,0 +1,107 @@ +using System.Collections.Immutable; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.QueryableBuilding; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace Benchmarks.Tools; + +/// +/// Never calls into instances. +/// +internal sealed class NeverResourceDefinitionAccessor : IResourceDefinitionAccessor +{ + bool IResourceDefinitionAccessor.IsReadOnlyRequest => throw new NotImplementedException(); + public IQueryableBuilder QueryableBuilder => throw new NotImplementedException(); + + public IImmutableSet OnApplyIncludes(ResourceType resourceType, IImmutableSet existingIncludes) + { + return existingIncludes; + } + + public FilterExpression? OnApplyFilter(ResourceType resourceType, FilterExpression? existingFilter) + { + return existingFilter; + } + + public SortExpression? OnApplySort(ResourceType resourceType, SortExpression? existingSort) + { + return existingSort; + } + + public PaginationExpression? OnApplyPagination(ResourceType resourceType, PaginationExpression? existingPagination) + { + return existingPagination; + } + + public SparseFieldSetExpression? OnApplySparseFieldSet(ResourceType resourceType, SparseFieldSetExpression? existingSparseFieldSet) + { + return existingSparseFieldSet; + } + + public object? GetQueryableHandlerForQueryStringParameter(Type resourceClrType, string parameterName) + { + return null; + } + + public IDictionary? GetMeta(ResourceType resourceType, IIdentifiable resourceInstance) + { + return null; + } + + public Task OnPrepareWriteAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, + IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.FromResult(rightResourceId); + } + + public Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task OnAddToRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task OnWritingAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public Task OnWriteSucceededAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + return Task.CompletedTask; + } + + public void OnDeserialize(IIdentifiable resource) + { + } + + public void OnSerialize(IIdentifiable resource) + { + } +} diff --git a/benchmarks/Tools/NoMetaBuilder.cs b/benchmarks/Tools/NoMetaBuilder.cs new file mode 100644 index 0000000000..db3ed7857e --- /dev/null +++ b/benchmarks/Tools/NoMetaBuilder.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Serialization.Response; + +namespace Benchmarks.Tools; + +/// +/// Doesn't produce any top-level meta. +/// +internal sealed class NoMetaBuilder : IMetaBuilder +{ + public void Add(IDictionary values) + { + } + + public IDictionary? Build() + { + return null; + } +} diff --git a/cleanupcode.ps1 b/cleanupcode.ps1 index 6db01a863a..b35d1cb215 100644 --- a/cleanupcode.ps1 +++ b/cleanupcode.ps1 @@ -1,17 +1,44 @@ #Requires -Version 7.0 -# This script reformats the entire codebase to make it compliant with our coding guidelines. +# This script reformats (part of) the codebase to make it compliant with our coding guidelines. -dotnet tool restore +param( + # Git branch name or base commit hash to reformat only the subset of changed files. Omit for all files. + [string] $revision +) -if ($LASTEXITCODE -ne 0) { - throw "Tool restore failed with exit code $LASTEXITCODE" +function VerifySuccessExitCode { + if ($LastExitCode -ne 0) { + throw "Command failed with exit code $LastExitCode." + } } +dotnet tool restore +VerifySuccessExitCode + dotnet restore +VerifySuccessExitCode -if ($LASTEXITCODE -ne 0) { - throw "Package restore failed with exit code $LASTEXITCODE" -} +if ($revision) { + $headCommitHash = git rev-parse HEAD + VerifySuccessExitCode -dotnet regitlint -s JsonApiDotNetCore.sln --print-command --disable-jb-path-hack --jb --profile='\"JADNC Full Cleanup\"' --jb --properties:Configuration=Release --jb --verbosity=WARN + $baseCommitHash = git rev-parse $revision + VerifySuccessExitCode + + if ($baseCommitHash -eq $headCommitHash) { + Write-Output "Running code cleanup on staged/unstaged files." + dotnet regitlint -s JsonApiDotNetCore.sln --print-command --skip-tool-check --max-runs=5 --jb --dotnetcoresdk=$(dotnet --version) --jb-profile="JADNC Full Cleanup" --jb --properties:Configuration=Release --jb --properties:RunAnalyzers=false --jb --verbosity=WARN -f staged,modified + VerifySuccessExitCode + } + else { + Write-Output "Running code cleanup on commit range $baseCommitHash..$headCommitHash, including staged/unstaged files." + dotnet regitlint -s JsonApiDotNetCore.sln --print-command --skip-tool-check --max-runs=5 --jb --dotnetcoresdk=$(dotnet --version) --jb-profile="JADNC Full Cleanup" --jb --properties:Configuration=Release --jb --properties:RunAnalyzers=false --jb --verbosity=WARN -f staged,modified,commits -a $headCommitHash -b $baseCommitHash + VerifySuccessExitCode + } +} +else { + Write-Output "Running code cleanup on all files." + dotnet regitlint -s JsonApiDotNetCore.sln --print-command --skip-tool-check --jb --dotnetcoresdk=$(dotnet --version) --jb-profile="JADNC Full Cleanup" --jb --properties:Configuration=Release --jb --properties:RunAnalyzers=false --jb --verbosity=WARN + VerifySuccessExitCode +} diff --git a/docs/README.md b/docs/README.md index bd33197f00..af8b89537f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,17 +1,9 @@ # Intro -Documentation for JsonApiDotNetCore is produced using [DocFX](https://dotnet.github.io/docfx/) from several files in this directory. +Documentation for JsonApiDotNetCore is produced using [docfx](https://dotnet.github.io/docfx/) from several files in this directory. In addition, the example request/response pairs are generated by executing `curl` commands against the GettingStarted project. # Installation -Run the following commands once to setup your system: - -``` -choco install docfx -y -``` - -``` -npm install -g httpserver -``` +You need to have 'npm' installed. Download Node.js from https://nodejs.org/. # Running The next command regenerates the documentation website and opens it in your default browser: diff --git a/docs/api/JsonApiDotNetCore.Controllers.Annotations.NoHttpPatchAttribute.md b/docs/api/JsonApiDotNetCore.Controllers.Annotations.NoHttpPatchAttribute.md new file mode 100644 index 0000000000..b63f67faec --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Controllers.Annotations.NoHttpPatchAttribute.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Resources.Annotations.ResourceAttribute.html#JsonApiDotNetCore_Resources_Annotations_ResourceAttribute_GenerateControllerEndpoints +--- diff --git a/docs/api/JsonApiDotNetCore.Controllers.JsonApiCommandController-1.md b/docs/api/JsonApiDotNetCore.Controllers.JsonApiCommandController-1.md new file mode 100644 index 0000000000..5d980615ff --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Controllers.JsonApiCommandController-1.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Controllers.JsonApiCommandController-2.html +--- diff --git a/docs/api/JsonApiDotNetCore.Controllers.ModelStateViolation.md b/docs/api/JsonApiDotNetCore.Controllers.ModelStateViolation.md new file mode 100644 index 0000000000..9414c98cc4 --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Controllers.ModelStateViolation.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Errors.InvalidModelStateException.html +--- diff --git a/docs/api/JsonApiDotNetCore.Diagnostics.CascadingCodeTimer.md b/docs/api/JsonApiDotNetCore.Diagnostics.CascadingCodeTimer.md new file mode 100644 index 0000000000..1b27f4c576 --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Diagnostics.CascadingCodeTimer.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Diagnostics.ICodeTimer.html +--- diff --git a/docs/api/JsonApiDotNetCore.Errors.ResourceIdInCreateResourceNotAllowedException.md b/docs/api/JsonApiDotNetCore.Errors.ResourceIdInCreateResourceNotAllowedException.md new file mode 100644 index 0000000000..bd889e5346 --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Errors.ResourceIdInCreateResourceNotAllowedException.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Errors.ResourceAlreadyExistsException.html +--- diff --git a/docs/api/JsonApiDotNetCore.Errors.ResourceTypeMismatchException.md b/docs/api/JsonApiDotNetCore.Errors.ResourceTypeMismatchException.md new file mode 100644 index 0000000000..f840a3f3aa --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Errors.ResourceTypeMismatchException.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Errors.InvalidRequestBodyException.html +--- diff --git a/docs/api/JsonApiDotNetCore.Hooks.IResourceHookExecutorFacade.md b/docs/api/JsonApiDotNetCore.Hooks.IResourceHookExecutorFacade.md new file mode 100644 index 0000000000..59094b11cc --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Hooks.IResourceHookExecutorFacade.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Resources.IResourceDefinition-2.html +--- diff --git a/docs/api/JsonApiDotNetCore.Hooks.Internal.Discovery.IHooksDiscovery.md b/docs/api/JsonApiDotNetCore.Hooks.Internal.Discovery.IHooksDiscovery.md new file mode 100644 index 0000000000..59094b11cc --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Hooks.Internal.Discovery.IHooksDiscovery.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Resources.IResourceDefinition-2.html +--- diff --git a/docs/api/JsonApiDotNetCore.Hooks.Internal.Execution.DiffableResourceHashSet-1.md b/docs/api/JsonApiDotNetCore.Hooks.Internal.Execution.DiffableResourceHashSet-1.md new file mode 100644 index 0000000000..4cf783422b --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Hooks.Internal.Execution.DiffableResourceHashSet-1.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Resources.JsonApiResourceDefinition-2.html +--- diff --git a/docs/api/JsonApiDotNetCore.Hooks.Internal.Execution.IResourceHashSet-1.md b/docs/api/JsonApiDotNetCore.Hooks.Internal.Execution.IResourceHashSet-1.md new file mode 100644 index 0000000000..59094b11cc --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Hooks.Internal.Execution.IResourceHashSet-1.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Resources.IResourceDefinition-2.html +--- diff --git a/docs/api/JsonApiDotNetCore.Hooks.Internal.ICreateHookExecutor.md b/docs/api/JsonApiDotNetCore.Hooks.Internal.ICreateHookExecutor.md new file mode 100644 index 0000000000..dacf9c60b1 --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Hooks.Internal.ICreateHookExecutor.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Resources.IResourceDefinition-2.html#JsonApiDotNetCore_Resources_IResourceDefinition_2_OnWritingAsync__0_JsonApiDotNetCore_Middleware_WriteOperationKind_System_Threading_CancellationToken_ +--- diff --git a/docs/api/JsonApiDotNetCore.Hooks.Internal.IUpdateHookExecutor.md b/docs/api/JsonApiDotNetCore.Hooks.Internal.IUpdateHookExecutor.md new file mode 100644 index 0000000000..dacf9c60b1 --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Hooks.Internal.IUpdateHookExecutor.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Resources.IResourceDefinition-2.html#JsonApiDotNetCore_Resources_IResourceDefinition_2_OnWritingAsync__0_JsonApiDotNetCore_Middleware_WriteOperationKind_System_Threading_CancellationToken_ +--- diff --git a/docs/api/JsonApiDotNetCore.Middleware.JsonApiExtension.md b/docs/api/JsonApiDotNetCore.Middleware.JsonApiExtension.md new file mode 100644 index 0000000000..5ce9d0e02c --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Middleware.JsonApiExtension.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Middleware.JsonApiMediaTypeExtension.html +--- diff --git a/docs/api/JsonApiDotNetCore.Queries.Expressions.CollectionNotEmptyExpression.md b/docs/api/JsonApiDotNetCore.Queries.Expressions.CollectionNotEmptyExpression.md new file mode 100644 index 0000000000..05c7012a73 --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Queries.Expressions.CollectionNotEmptyExpression.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Queries.Expressions.HasExpression.html +--- diff --git a/docs/api/JsonApiDotNetCore.Queries.Internal.IEvaluatedIncludeCache.md b/docs/api/JsonApiDotNetCore.Queries.Internal.IEvaluatedIncludeCache.md new file mode 100644 index 0000000000..d990b723a8 --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Queries.Internal.IEvaluatedIncludeCache.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Queries.IEvaluatedIncludeCache.html +--- diff --git a/docs/api/JsonApiDotNetCore.Queries.Internal.Parsing.IncludeParser.md b/docs/api/JsonApiDotNetCore.Queries.Internal.Parsing.IncludeParser.md new file mode 100644 index 0000000000..b7cc547960 --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Queries.Internal.Parsing.IncludeParser.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Queries.Parsing.IncludeParser.html +--- diff --git a/docs/api/JsonApiDotNetCore.Queries.Internal.Parsing.Keywords.md b/docs/api/JsonApiDotNetCore.Queries.Internal.Parsing.Keywords.md new file mode 100644 index 0000000000..a1a604ffb2 --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Queries.Internal.Parsing.Keywords.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Queries.Parsing.Keywords.html +--- diff --git a/docs/api/JsonApiDotNetCore.Queries.Internal.Parsing.QueryExpressionParser.md b/docs/api/JsonApiDotNetCore.Queries.Internal.Parsing.QueryExpressionParser.md new file mode 100644 index 0000000000..d3574188c1 --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Queries.Internal.Parsing.QueryExpressionParser.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Queries.Parsing.QueryExpressionParser.html +--- diff --git a/docs/api/JsonApiDotNetCore.Queries.Internal.Parsing.QueryStringParameterScopeParser.md b/docs/api/JsonApiDotNetCore.Queries.Internal.Parsing.QueryStringParameterScopeParser.md new file mode 100644 index 0000000000..0403d40ebc --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Queries.Internal.Parsing.QueryStringParameterScopeParser.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Queries.Parsing.QueryStringParameterScopeParser.html +--- diff --git a/docs/api/JsonApiDotNetCore.Queries.Internal.Parsing.QueryTokenizer.md b/docs/api/JsonApiDotNetCore.Queries.Internal.Parsing.QueryTokenizer.md new file mode 100644 index 0000000000..0cf46bdf57 --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Queries.Internal.Parsing.QueryTokenizer.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Queries.Parsing.QueryTokenizer.html +--- diff --git a/docs/api/JsonApiDotNetCore.Queries.Internal.QueryableBuilding.LambdaScope.md b/docs/api/JsonApiDotNetCore.Queries.Internal.QueryableBuilding.LambdaScope.md new file mode 100644 index 0000000000..1884dc786b --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Queries.Internal.QueryableBuilding.LambdaScope.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Queries.QueryableBuilding.LambdaScope.html +--- diff --git a/docs/api/JsonApiDotNetCore.Queries.Internal.QueryableBuilding.SelectClauseBuilder.md b/docs/api/JsonApiDotNetCore.Queries.Internal.QueryableBuilding.SelectClauseBuilder.md new file mode 100644 index 0000000000..005a6b211f --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Queries.Internal.QueryableBuilding.SelectClauseBuilder.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Queries.QueryableBuilding.SelectClauseBuilder.html +--- diff --git a/docs/api/JsonApiDotNetCore.Queries.Internal.QueryableBuilding.WhereClauseBuilder.md b/docs/api/JsonApiDotNetCore.Queries.Internal.QueryableBuilding.WhereClauseBuilder.md new file mode 100644 index 0000000000..5ff3e97e5d --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Queries.Internal.QueryableBuilding.WhereClauseBuilder.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Queries.QueryableBuilding.WhereClauseBuilder.html +--- diff --git a/docs/api/JsonApiDotNetCore.QueryStrings.IDefaultsQueryStringParameterReader.md b/docs/api/JsonApiDotNetCore.QueryStrings.IDefaultsQueryStringParameterReader.md new file mode 100644 index 0000000000..d46a266812 --- /dev/null +++ b/docs/api/JsonApiDotNetCore.QueryStrings.IDefaultsQueryStringParameterReader.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.QueryStrings.IQueryStringParameterReader.html +--- diff --git a/docs/api/JsonApiDotNetCore.QueryStrings.Internal.DefaultsQueryStringParameterReader.md b/docs/api/JsonApiDotNetCore.QueryStrings.Internal.DefaultsQueryStringParameterReader.md new file mode 100644 index 0000000000..0c6da2ca56 --- /dev/null +++ b/docs/api/JsonApiDotNetCore.QueryStrings.Internal.DefaultsQueryStringParameterReader.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.QueryStrings.QueryStringParameterReader.html +--- diff --git a/docs/api/JsonApiDotNetCore.QueryStrings.Internal.FilterQueryStringParameterReader.md b/docs/api/JsonApiDotNetCore.QueryStrings.Internal.FilterQueryStringParameterReader.md new file mode 100644 index 0000000000..6456874854 --- /dev/null +++ b/docs/api/JsonApiDotNetCore.QueryStrings.Internal.FilterQueryStringParameterReader.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.QueryStrings.FilterQueryStringParameterReader.html +--- diff --git a/docs/api/JsonApiDotNetCore.QueryStrings.Internal.IncludeQueryStringParameterReader.md b/docs/api/JsonApiDotNetCore.QueryStrings.Internal.IncludeQueryStringParameterReader.md new file mode 100644 index 0000000000..d8ceb2d5fa --- /dev/null +++ b/docs/api/JsonApiDotNetCore.QueryStrings.Internal.IncludeQueryStringParameterReader.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.QueryStrings.IncludeQueryStringParameterReader.html +--- diff --git a/docs/api/JsonApiDotNetCore.QueryStrings.Internal.PaginationQueryStringParameterReader.md b/docs/api/JsonApiDotNetCore.QueryStrings.Internal.PaginationQueryStringParameterReader.md new file mode 100644 index 0000000000..d0fc4348cf --- /dev/null +++ b/docs/api/JsonApiDotNetCore.QueryStrings.Internal.PaginationQueryStringParameterReader.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.QueryStrings.PaginationQueryStringParameterReader.html +--- diff --git a/docs/api/JsonApiDotNetCore.QueryStrings.Internal.ResourceDefinitionQueryableParameterReader.md b/docs/api/JsonApiDotNetCore.QueryStrings.Internal.ResourceDefinitionQueryableParameterReader.md new file mode 100644 index 0000000000..ef485e70a7 --- /dev/null +++ b/docs/api/JsonApiDotNetCore.QueryStrings.Internal.ResourceDefinitionQueryableParameterReader.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.QueryStrings.ResourceDefinitionQueryableParameterReader.html +--- diff --git a/docs/api/JsonApiDotNetCore.QueryStrings.Internal.md b/docs/api/JsonApiDotNetCore.QueryStrings.Internal.md new file mode 100644 index 0000000000..9535aea472 --- /dev/null +++ b/docs/api/JsonApiDotNetCore.QueryStrings.Internal.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.QueryStrings.html +--- diff --git a/docs/api/JsonApiDotNetCore.Resources.Internal.md b/docs/api/JsonApiDotNetCore.Resources.Internal.md new file mode 100644 index 0000000000..f547f0925d --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Resources.Internal.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Resources.html +--- diff --git a/docs/api/JsonApiDotNetCore.Resources.ResourceHooksDefinition-1.md b/docs/api/JsonApiDotNetCore.Resources.ResourceHooksDefinition-1.md new file mode 100644 index 0000000000..4cf783422b --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Resources.ResourceHooksDefinition-1.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Resources.JsonApiResourceDefinition-2.html +--- diff --git a/docs/api/JsonApiDotNetCore.Serialization.BaseDeserializer.md b/docs/api/JsonApiDotNetCore.Serialization.BaseDeserializer.md new file mode 100644 index 0000000000..e4bd9d0bf0 --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Serialization.BaseDeserializer.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Serialization.Request.Adapters.DocumentAdapter.html +--- diff --git a/docs/api/JsonApiDotNetCore.Serialization.BaseSerializer.md b/docs/api/JsonApiDotNetCore.Serialization.BaseSerializer.md new file mode 100644 index 0000000000..af24d07eb0 --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Serialization.BaseSerializer.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Serialization.Response.ResponseModelAdapter.html +--- diff --git a/docs/api/JsonApiDotNetCore.Serialization.Building.IIncludedResourceObjectBuilder.md b/docs/api/JsonApiDotNetCore.Serialization.Building.IIncludedResourceObjectBuilder.md new file mode 100644 index 0000000000..79a6ec638b --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Serialization.Building.IIncludedResourceObjectBuilder.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Serialization.Response.ResponseModelAdapter.html#JsonApiDotNetCore_Serialization_Response_ResponseModelAdapter_ConvertResource_JsonApiDotNetCore_Resources_IIdentifiable_JsonApiDotNetCore_Configuration_ResourceType_JsonApiDotNetCore_Middleware_EndpointKind_ +--- diff --git a/docs/api/JsonApiDotNetCore.Serialization.Building.IResourceObjectBuilder.md b/docs/api/JsonApiDotNetCore.Serialization.Building.IResourceObjectBuilder.md new file mode 100644 index 0000000000..79a6ec638b --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Serialization.Building.IResourceObjectBuilder.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Serialization.Response.ResponseModelAdapter.html#JsonApiDotNetCore_Serialization_Response_ResponseModelAdapter_ConvertResource_JsonApiDotNetCore_Resources_IIdentifiable_JsonApiDotNetCore_Configuration_ResourceType_JsonApiDotNetCore_Middleware_EndpointKind_ +--- diff --git a/docs/api/JsonApiDotNetCore.Serialization.Building.ResourceObjectBuilderSettings.md b/docs/api/JsonApiDotNetCore.Serialization.Building.ResourceObjectBuilderSettings.md new file mode 100644 index 0000000000..03cbfa162e --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Serialization.Building.ResourceObjectBuilderSettings.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Configuration.JsonApiOptions.html#JsonApiDotNetCore_Configuration_JsonApiOptions_SerializerOptions +--- diff --git a/docs/api/JsonApiDotNetCore.Serialization.Client.Internal.ManyResponse-1.md b/docs/api/JsonApiDotNetCore.Serialization.Client.Internal.ManyResponse-1.md new file mode 100644 index 0000000000..2b6744f22c --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Serialization.Client.Internal.ManyResponse-1.md @@ -0,0 +1,3 @@ +--- +redirect_url: ../usage/openapi-client.html +--- diff --git a/docs/api/JsonApiDotNetCore.Serialization.Client.Internal.RequestSerializer.md b/docs/api/JsonApiDotNetCore.Serialization.Client.Internal.RequestSerializer.md new file mode 100644 index 0000000000..2b6744f22c --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Serialization.Client.Internal.RequestSerializer.md @@ -0,0 +1,3 @@ +--- +redirect_url: ../usage/openapi-client.html +--- diff --git a/docs/api/JsonApiDotNetCore.Serialization.Client.Internal.md b/docs/api/JsonApiDotNetCore.Serialization.Client.Internal.md new file mode 100644 index 0000000000..2b6744f22c --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Serialization.Client.Internal.md @@ -0,0 +1,3 @@ +--- +redirect_url: ../usage/openapi-client.html +--- diff --git a/docs/api/JsonApiDotNetCore.Serialization.IJsonApiDeserializer.md b/docs/api/JsonApiDotNetCore.Serialization.IJsonApiDeserializer.md new file mode 100644 index 0000000000..767e0c94d2 --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Serialization.IJsonApiDeserializer.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Serialization.Request.Adapters.IDocumentAdapter.html +--- diff --git a/docs/api/JsonApiDotNetCore.Serialization.IJsonApiWriter.md b/docs/api/JsonApiDotNetCore.Serialization.IJsonApiWriter.md new file mode 100644 index 0000000000..b9bbf20b7c --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Serialization.IJsonApiWriter.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Serialization.Response.IJsonApiWriter.html +--- diff --git a/docs/api/JsonApiDotNetCore.Serialization.Objects.IResourceIdentity.md b/docs/api/JsonApiDotNetCore.Serialization.Objects.IResourceIdentity.md new file mode 100644 index 0000000000..4a3f2ca610 --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Serialization.Objects.IResourceIdentity.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Serialization.Objects.ResourceIdentity.html +--- diff --git a/docs/api/JsonApiDotNetCore.Serialization.ResponseSerializerFactory.md b/docs/api/JsonApiDotNetCore.Serialization.ResponseSerializerFactory.md new file mode 100644 index 0000000000..03cbfa162e --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Serialization.ResponseSerializerFactory.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Configuration.JsonApiOptions.html#JsonApiDotNetCore_Configuration_JsonApiOptions_SerializerOptions +--- diff --git a/docs/api/JsonApiDotNetCore.Services.IGetAllService-1.md b/docs/api/JsonApiDotNetCore.Services.IGetAllService-1.md new file mode 100644 index 0000000000..36fcd2e43e --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Services.IGetAllService-1.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Services.IGetAllService-2.html +--- diff --git a/docs/api/JsonApiDotNetCore.Services.IRemoveFromRelationshipService-1.md b/docs/api/JsonApiDotNetCore.Services.IRemoveFromRelationshipService-1.md new file mode 100644 index 0000000000..5df240af1c --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Services.IRemoveFromRelationshipService-1.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Services.IRemoveFromRelationshipService-2.html +--- diff --git a/docs/api/JsonApiDotNetCore.Services.IResourceCommandService-1.md b/docs/api/JsonApiDotNetCore.Services.IResourceCommandService-1.md new file mode 100644 index 0000000000..fedee0f018 --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Services.IResourceCommandService-1.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Services.IResourceCommandService-2.html +--- diff --git a/docs/api/JsonApiDotNetCore.Services.IResourceQueryService-1.md b/docs/api/JsonApiDotNetCore.Services.IResourceQueryService-1.md new file mode 100644 index 0000000000..0801fc22f9 --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Services.IResourceQueryService-1.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Services.IResourceQueryService-2.html +--- diff --git a/docs/api/JsonApiDotNetCore.Services.JsonApiResourceService-1.md b/docs/api/JsonApiDotNetCore.Services.JsonApiResourceService-1.md new file mode 100644 index 0000000000..5a2be335cc --- /dev/null +++ b/docs/api/JsonApiDotNetCore.Services.JsonApiResourceService-1.md @@ -0,0 +1,3 @@ +--- +redirect_url: JsonApiDotNetCore.Services.JsonApiResourceService-2.html +--- diff --git a/docs/api/index.md b/docs/api/index.md index 7eb109b9af..8cdc3c745b 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -1,9 +1,93 @@ -# API +# Public API surface -This section documents the package API and is generated from the XML source comments. +This topic documents the public API, which is generated from the triple-slash XML documentation comments in source code. +Commonly used types are listed in the following sections. -## Common APIs +## Setup -- [`JsonApiOptions`](JsonApiDotNetCore.Configuration.JsonApiOptions.yml) -- [`IResourceGraph`](JsonApiDotNetCore.Configuration.IResourceGraph.yml) -- [`JsonApiResourceDefinition`](JsonApiDotNetCore.Resources.JsonApiResourceDefinition-2.yml) +- implements +- implements + - + - implements + - and + - + - + - + - + - +- , (OpenAPI) +- +- implements + - + - + +## Query strings + +- implements + - implements + - and + - implements + - implements + - implements + - implements + - implements + - implements + - implements + - implements + - implements + - implements +- + - + - + - + - + - + - + - +- implements + - implements + - implements + - implements + - implements + - implements + +## Request pipeline + +- implements + - + - +- implements + - implements + - + - implements + - implements + - implements + - implements + - implements + - implements +- + - implements +- implements +- implements + - implements +- implements + - + - + +## Serialization + +- implements + - implements + - implements + - implements +- implements + - implements + - implements +- +- +- implements + +## Error handling + +- implements + - implements diff --git a/docs/build-dev.ps1 b/docs/build-dev.ps1 index a5ee0ff947..348233253e 100644 --- a/docs/build-dev.ps1 +++ b/docs/build-dev.ps1 @@ -1,17 +1,54 @@ -# This script assumes that you have already installed docfx and httpserver. -# If that's not the case, run the next commands: -# choco install docfx -y -# npm install -g httpserver +#Requires -Version 7.3 -Remove-Item _site -Recurse -ErrorAction Ignore +# This script builds the documentation website, starts a web server and opens the site in your browser. Intended for local development. -dotnet build .. --configuration Release -Invoke-Expression ./generate-examples.ps1 +param( + # Specify -NoBuild to skip code build and examples generation. This runs faster, so handy when only editing Markdown files. + [switch] $NoBuild=$False +) -docfx ./docfx.json -Copy-Item home/*.html _site/ -Copy-Item home/*.ico _site/ -Copy-Item -Recurse home/assets/* _site/styles/ +function VerifySuccessExitCode { + if ($LastExitCode -ne 0) { + throw "Command failed with exit code $LastExitCode." + } +} + +function EnsureHttpServerIsInstalled { + if ((Get-Command "npm" -ErrorAction SilentlyContinue) -eq $null) { + throw "Unable to find npm in your PATH. please install Node.js first." + } + + npm list --depth 1 --global httpserver >$null + + if ($LastExitCode -eq 1) { + npm install -g httpserver + } +} + +EnsureHttpServerIsInstalled +VerifySuccessExitCode + +if (-Not $NoBuild -Or -Not (Test-Path -Path _site)) { + Remove-Item _site\* -Recurse -ErrorAction Ignore + + dotnet build .. --configuration Release + VerifySuccessExitCode + + Invoke-Expression ./generate-examples.ps1 +} else { + Remove-Item _site\* -Recurse -ErrorAction Ignore +} + +dotnet tool restore +VerifySuccessExitCode + +dotnet docfx ./docfx.json --warningsAsErrors true +VerifySuccessExitCode + +Copy-Item -Force home/*.html _site/ +Copy-Item -Force home/*.ico _site/ +New-Item -Force _site/styles -ItemType Directory | Out-Null +Copy-Item -Force -Recurse home/assets/* _site/styles/ cd _site $webServerJob = httpserver & @@ -23,4 +60,4 @@ Write-Host "Web server started. Press Enter to close." $key = [Console]::ReadKey() Stop-Job -Id $webServerJob.Id -Get-job | Remove-Job +Get-job | Remove-Job -Force diff --git a/docs/docfx.json b/docs/docfx.json index 7fdafa0fe5..b073247dd3 100644 --- a/docs/docfx.json +++ b/docs/docfx.json @@ -1,50 +1,55 @@ { - "metadata": [ + "$schema": "https://raw.githubusercontent.com/dotnet/docfx/main/schemas/docfx.schema.json", + "metadata": [ + { + "properties": { + "ProduceReferenceAssembly": "true" + }, + "src": [ { - "src": [ - { - "files": [ "**/JsonApiDotNetCore.csproj","**/JsonApiDotNetCore.Annotations.csproj" ], - "src": "../" - } - ], - "dest": "api", - "disableGitFeatures": false + "files": [ + "**/JsonApiDotNetCore.csproj", + "**/JsonApiDotNetCore.Annotations.csproj", + "**/JsonApiDotNetCore.OpenApi.Swashbuckle.csproj", + "**/JsonApiDotNetCore.OpenApi.Client.NSwag.csproj", + "**/JsonApiDotNetCore.OpenApi.Client.Kiota" + ], + "src": "../" } + ], + "output": "api" + } + ], + "build": { + "content": [ + { + "files": "**.{md|yml}", + "exclude": [ + "**/README.md" + ] + } + ], + "resource": [ + { + "files": [ + "diagrams/*.svg" + ] + } + ], + "output": "_site", + "template": [ + "default", + "modern", + "template" ], - "build": { - "content": [ - { - "files": [ - "api/**.yml", - "api/index.md", - "getting-started/**.md", - "getting-started/**/toc.yml", - "usage/**.md", - "request-examples/**.md", - "internals/**.md", - "toc.yml", - "*.md" - ] - } - ], - "resource": [ - { - "files": [ "diagrams/*.svg" ] - } - ], - "overwrite": [ - { - "exclude": [ "obj/**", "_site/**" ] - } - ], - "dest": "_site", - "globalMetadataFiles": [], - "fileMetadataFiles": [], - "template": [ "default" ], - "postProcessors": [], - "noLangKeyword": false, - "keepFileLink": false, - "cleanupCacheHistory": false, - "disableGitFeatures": false + "globalMetadata": { + "_appLogoPath": "styles/img/favicon.png", + "_googleAnalyticsTagId": "G-78GTGF1FM2" + }, + "sitemap": { + "baseUrl": "https://www.jsonapi.net", + "priority": 0.5, + "changefreq": "weekly" } + } } diff --git a/docs/ext/openapi/index.md b/docs/ext/openapi/index.md new file mode 100644 index 0000000000..20aad7b305 --- /dev/null +++ b/docs/ext/openapi/index.md @@ -0,0 +1,127 @@ +# JSON:API Extension for OpenAPI + +This extension facilitates using OpenAPI client generators targeting JSON:API documents. + +In JSON:API, a resource object contains the `type` member, which defines the structure of nested [attributes](https://jsonapi.org/format/#document-resource-object-attributes) and [relationships](https://jsonapi.org/format/#document-resource-object-relationships) objects. +While OpenAPI supports such constraints using `allOf` inheritance with a discriminator property for the `data` member, +it provides no way to express that the discriminator recursively applies to nested objects. + +This extension addresses that limitation by defining additional discriminator properties to guide code generation tools. + +## URI + +This extension has the URI `https://www.jsonapi.net/ext/openapi`. +Because code generators often choke on the double quotes in `Accept` and `Content-Type` HTTP header values, a relaxed form is also permitted: `openapi`. + +For example, the following `Content-Type` header: + +```http +Content-Type: application/vnd.api+json; ext="https://www.jsonapi.net/ext/openapi" +``` + +is equivalent to: + +```http +Content-Type: application/vnd.api+json; ext=openapi +``` + +To avoid the need for double quotes when multiple extensions are used, the following relaxed form can be used: + +```http +Content-Type: application/vnd.api+json; ext=openapi; ext=atomic +``` + +> [!NOTE] +> The [base specification](https://jsonapi.org/format/#media-type-parameter-rules) *forbids* the use of multiple `ext` parameters +> and *requires* that each extension name must be a URI. +> This extension relaxes both constraints for practical reasons, to workaround bugs in client generators that produce broken code otherwise. + +## Namespace + +This extension uses the namespace `openapi`. + +> [!NOTE] +> JSON:API extensions can only introduce new document members using a reserved namespace as a prefix. + +## Document Structure + +A document that supports this extension MAY include any of the top-level members allowed by the base specification, +including any members defined in the [Atomic Operations extension](https://jsonapi.org/ext/atomic/). + +### Resource Objects + +In addition to the members allowed by the base specification, the following member MAY be included +in [attributes](https://jsonapi.org/format/#document-resource-object-attributes) and [relationships](https://jsonapi.org/format/#document-resource-object-relationships) objects: + +* `openapi:discriminator` - A string that MUST be identical to the `type` member in the containing [resource object](https://jsonapi.org/format/#document-resource-objects). + +Here's how an article (i.e. a resource of type "articles") might appear in a document: + +```json +{ + "data": { + "type": "articles", + "id": "1", + "attributes": { + "openapi:discriminator": "articles", + "title": "Rails is Omakase" + }, + "relationships": { + "openapi:discriminator": "articles", + "author": { + "data": { "type": "people", "id": "9" } + } + } + } +} +``` + +### Atomic Operations + +In addition to the members allowed by the [Atomic Operations extension](https://jsonapi.org/ext/atomic/), +the following member MAY be included in elements of an `atomic:operations` array: + +* `openapi:discriminator` - A free-format string to facilitate generation of client code. + +For example: + +```http +POST /operations HTTP/1.1 +Host: example.org +Content-Type: application/vnd.api+json; ext="https://www.jsonapi.net/ext/openapi https://jsonapi.org/ext/atomic" +Accept: application/vnd.api+json; ext="https://www.jsonapi.net/ext/openapi https://jsonapi.org/ext/atomic" + +{ + "atomic:operations": [{ + "openapi:discriminator": "add-article", + "op": "add", + "data": { + "type": "articles", + "attributes": { + "openapi:discriminator": "articles", + "title": "JSON API paints my bikeshed!" + } + } + }] +} +``` + +## Processing + +A server MAY ignore the `openapi:discriminator` member in [attributes](https://jsonapi.org/format/#document-resource-object-attributes) and [relationships](https://jsonapi.org/format/#document-resource-object-relationships) objects from incoming requests. +A server SHOULD ignore the `openapi:discriminator` member in elements of an `atomic:operations` array. + +A server MUST include the `openapi:discriminator` member in [attributes](https://jsonapi.org/format/#document-resource-object-attributes) and [relationships](https://jsonapi.org/format/#document-resource-object-relationships) objects in outgoing responses. +The member value MUST be the same as the `type` member value of the containing resource object. + +A client MAY include the `openapi:discriminator` member in [attributes](https://jsonapi.org/format/#document-resource-object-attributes) and [relationships](https://jsonapi.org/format/#document-resource-object-relationships) objects in outgoing requests. +The member value MUST be the same as the `type` member value of the containing resource object. + +A client MAY include the `openapi:discriminator` member in elements of an `atomic:operations` array. + +### Processing Errors + +A server SHOULD validate that the value of the `openapi:discriminator` member in +[attributes](https://jsonapi.org/format/#document-resource-object-attributes) and [relationships](https://jsonapi.org/format/#document-resource-object-relationships) objects +is identical to the `type` member in the containing resource object. When validation fails, the server MUST respond with a `409 Conflict` +and SHOULD include a document with a top-level `errors` member that contains an error object. diff --git a/docs/generate-examples.ps1 b/docs/generate-examples.ps1 index 6f7f7dc574..ea6b2bd8f2 100644 --- a/docs/generate-examples.ps1 +++ b/docs/generate-examples.ps1 @@ -1,57 +1,76 @@ -#Requires -Version 7.0 +#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 + $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 + Get-Process -Id $webProcessId | Stop-Process -ErrorVariable stopErrorMessage + + if ($stopErrorMessage) { + throw "Failed to stop web server: $stopErrorMessage" + } } } 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 --framework net8.0 --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 -Remove-Item -Force -Path .\request-examples\*.json +try { + Remove-Item -Force -Path .\request-examples\*.json -$scriptFiles = Get-ChildItem .\request-examples\*.ps1 -foreach ($scriptFile in $scriptFiles) { - $jsonFileName = [System.IO.Path]::GetFileNameWithoutExtension($scriptFile.Name) + "_Response.json" + $scriptFiles = Get-ChildItem .\request-examples\*.ps1 + foreach ($scriptFile in $scriptFiles) { + $jsonFileName = [System.IO.Path]::GetFileNameWithoutExtension($scriptFile.Name) + "_Response.json" - Write-Output "Writing file: $jsonFileName" - & $scriptFile.FullName > .\request-examples\$jsonFileName + Write-Output "Writing file: $jsonFileName" + & $scriptFile.FullName > .\request-examples\$jsonFileName - if ($LastExitCode -ne 0) { - throw [System.Exception] "Example request from '$($scriptFile.Name)' failed with exit code $LastExitCode." + if ($LastExitCode -ne 0) { + throw "Example request from '$($scriptFile.Name)' failed with exit code $LastExitCode." + } } } - -Kill-WebServer +finally { + Stop-WebServer +} diff --git a/docs/getting-started/faq.md b/docs/getting-started/faq.md new file mode 100644 index 0000000000..c36a09f99d --- /dev/null +++ b/docs/getting-started/faq.md @@ -0,0 +1,3 @@ +--- +redirect_url: ../usage/faq.html +--- diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md new file mode 100644 index 0000000000..0b309e46eb --- /dev/null +++ b/docs/getting-started/index.md @@ -0,0 +1,5 @@ +# Getting Started + +The easiest way to get started is to run the [example project](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/src/Examples/GettingStarted). + +Or create your first JsonApiDotNetCore project by following the steps described [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/README.md#getting-started). diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md index bd210e0a76..b09e389c91 100644 --- a/docs/getting-started/install.md +++ b/docs/getting-started/install.md @@ -1,24 +1,3 @@ -# Installation - -Click [here](https://www.nuget.org/packages/JsonApiDotNetCore/) for the latest NuGet version. - -### CLI - -``` -dotnet add package JsonApiDotNetCore -``` - -### Visual Studio - -```powershell -Install-Package JsonApiDotNetCore -``` - -### *.csproj - -```xml - - - - -``` +--- +redirect_url: index.html +--- diff --git a/docs/getting-started/step-by-step.md b/docs/getting-started/step-by-step.md index 57090d2d09..b09e389c91 100644 --- a/docs/getting-started/step-by-step.md +++ b/docs/getting-started/step-by-step.md @@ -1,134 +1,3 @@ -# Step-By-Step Guide to a Running API - -The most basic use case leverages Entity Framework Core. -The shortest path to a running API looks like: - -- Create a new API project -- Install -- Define models -- Define the DbContext -- Add services and middleware -- Seed the database -- Start the API - -This page will walk you through the **simplest** use case. More detailed examples can be found in the detailed usage subsections. - -### Create a new API project - -``` -mkdir MyApi -cd MyApi -dotnet new webapi -``` - -### Install - -``` -dotnet add package JsonApiDotNetCore - -- or - - -Install-Package JsonApiDotNetCore -``` - -### Define models - -Define your domain models such that they implement `IIdentifiable`. -The easiest way to do this is to inherit from `Identifiable`. - -```c# -#nullable enable - -[Resource] -public class Person : Identifiable -{ - [Attr] - public string Name { get; set; } = null!; -} -``` - -### Define the DbContext - -Nothing special here, just an ordinary `DbContext`. - -``` -public class AppDbContext : DbContext -{ - public DbSet People => Set(); - - public AppDbContext(DbContextOptions options) - : base(options) - { - } -} -``` - -### Add services and middleware - -Finally, register the services and middleware by adding them to your Program.cs: - -```c# -WebApplicationBuilder builder = WebApplication.CreateBuilder(args); - -// Add services to the container. - -// Add the Entity Framework Core DbContext like you normally would. -builder.Services.AddDbContext(options => -{ - string connectionString = GetConnectionString(); - - // Use whatever provider you want, this is just an example. - options.UseNpgsql(connectionString); -}); - -// Add JsonApiDotNetCore services. -builder.Services.AddJsonApi(); - -WebApplication app = builder.Build(); - -// Configure the HTTP request pipeline. - -app.UseRouting(); - -// Add JsonApiDotNetCore middleware. -app.UseJsonApi(); - -app.MapControllers(); - -app.Run(); -``` - -### Seed the database - -One way to seed the database is from your Program.cs: - -```c# -await CreateDatabaseAsync(app.Services); - -app.Run(); - -static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) -{ - await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); - - var dbContext = scope.ServiceProvider.GetRequiredService(); - await dbContext.Database.EnsureCreatedAsync(); - - if (!dbContext.People.Any()) - { - dbContext.People.Add(new Person - { - Name = "John Doe" - }); - - await dbContext.SaveChangesAsync(); - } -} -``` - -### Start the API - -``` -dotnet run -curl http://localhost:5000/people -``` +--- +redirect_url: index.html +--- diff --git a/docs/getting-started/toc.yml b/docs/getting-started/toc.yml deleted file mode 100644 index 4a2a008591..0000000000 --- a/docs/getting-started/toc.yml +++ /dev/null @@ -1,5 +0,0 @@ -- name: Installation - href: install.md - -- name: Step By Step - href: step-by-step.md \ No newline at end of file diff --git a/docs/home/assets/dark-mode.css b/docs/home/assets/dark-mode.css new file mode 100644 index 0000000000..80e9bd516d --- /dev/null +++ b/docs/home/assets/dark-mode.css @@ -0,0 +1,16 @@ +html { + background-color: #171717 !important; + filter: invert(100%) hue-rotate(180deg) brightness(105%) contrast(85%); + -webkit-filter: invert(100%) hue-rotate(180deg) brightness(105%) contrast(85%); +} + +body { + background-color: #FFF !important; +} + +img, +video, +body * [style*="background-image"] { + filter: hue-rotate(180deg) contrast(100%) invert(100%); + -webkit-filter: hue-rotate(180deg) contrast(100%) invert(100%); +} diff --git a/docs/home/assets/fonts/icofont.eot b/docs/home/assets/fonts/icofont.eot new file mode 100644 index 0000000000..47790c2f50 Binary files /dev/null and b/docs/home/assets/fonts/icofont.eot differ diff --git a/docs/home/assets/fonts/icofont.svg b/docs/home/assets/fonts/icofont.svg new file mode 100644 index 0000000000..685f2e87d3 --- /dev/null +++ b/docs/home/assets/fonts/icofont.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/docs/home/assets/fonts/icofont.ttf b/docs/home/assets/fonts/icofont.ttf new file mode 100644 index 0000000000..9d3b07d153 Binary files /dev/null and b/docs/home/assets/fonts/icofont.ttf differ diff --git a/docs/home/assets/fonts/icofont.woff b/docs/home/assets/fonts/icofont.woff new file mode 100644 index 0000000000..8f9f5aef91 Binary files /dev/null and b/docs/home/assets/fonts/icofont.woff differ diff --git a/docs/home/assets/fonts/icofont.woff2 b/docs/home/assets/fonts/icofont.woff2 new file mode 100644 index 0000000000..a5db6146e1 Binary files /dev/null and b/docs/home/assets/fonts/icofont.woff2 differ diff --git a/docs/home/assets/home.css b/docs/home/assets/home.css index bfd6f96e06..5314474112 100644 --- a/docs/home/assets/home.css +++ b/docs/home/assets/home.css @@ -95,7 +95,6 @@ h1, h2, h3, h4, h5, h6, .font-primary { margin-top: 72px; } - /*-------------------------------------------------------------- # Hero Section --------------------------------------------------------------*/ @@ -300,12 +299,6 @@ section { .breadcrumbs ol li { display: inline-block; } - - -} - -div[feature]:hover { - cursor: pointer; } /*-------------------------------------------------------------- @@ -401,6 +394,34 @@ div[feature]:hover { margin-bottom: 0; } +div[feature]:hover { + cursor: pointer; +} + +/*-------------------------------------------------------------- +# Sponsors +--------------------------------------------------------------*/ +.sponsors .icon-box { + padding: 30px; + position: relative; + overflow: hidden; + margin: 0 0 40px 0; + background: #fff; + box-shadow: 0 10px 29px 0 rgba(68, 88, 144, 0.1); + transition: all 0.3s ease-in-out; + border-radius: 15px; + text-align: center; + border-bottom: 3px solid #fff; +} + +.sponsors .icon-box:hover { + transform: translateY(-5px); + border-color: #ef7f4d; +} + +div[sponsor]:hover { + cursor: pointer; +} /*-------------------------------------------------------------- # Footer @@ -582,3 +603,11 @@ div[feature]:hover { padding: 3px 0; } } + +/*-------------------------------------------------------------- +# Theme selection +--------------------------------------------------------------*/ +.btn-theme:focus, +.btn-theme:active { + box-shadow: none !important; +} diff --git a/docs/home/assets/home.js b/docs/home/assets/home.js index cb8ac539bd..40e31c15ad 100644 --- a/docs/home/assets/home.js +++ b/docs/home/assets/home.js @@ -1,3 +1,31 @@ +function setTheme(theme) { + const darkModeStyleSheet = document.getElementById('dark-mode-style-sheet'); + const activeTheme = document.getElementById('active-theme'); + + if (theme === "auto") { + darkModeStyleSheet.disabled = !window.matchMedia("(prefers-color-scheme: dark)").matches; + activeTheme.className = "bi-circle-half"; + } + else if (theme === "dark") { + darkModeStyleSheet.disabled = false; + activeTheme.className = "bi bi-moon"; + } else if (theme === "light") { + darkModeStyleSheet.disabled = true; + activeTheme.className = "bi bi-sun"; + } + + localStorage.setItem("theme", theme) +} + +$('.theme-choice').click(function () { + setTheme(this.dataset.theme); +}) + +function initTheme() { + const theme = localStorage.getItem("theme") || "auto"; + setTheme(theme); +} + !(function($) { "use strict"; @@ -38,7 +66,6 @@ } }); - // Feature panels linking $('div[feature]#filter').on('click', () => navigateTo('usage/reading/filtering.html')); $('div[feature]#sort').on('click', () => navigateTo('usage/reading/sorting.html')); @@ -49,13 +76,19 @@ $('div[feature]#validation').on('click', () => navigateTo('usage/options.html#enable-modelstate-validation')); $('div[feature]#customizable').on('click', () => navigateTo('usage/extensibility/resource-definitions.html')); - const navigateTo = (url) => { - if (!window.getSelection().toString()){ + if (!window.getSelection().toString()) { window.location = url; } } + // Sponsor panels linking + $('div[sponsor]#jetbrains').on('click', () => navigateExternalTo('https://jb.gg/OpenSourceSupport')); + $('div[sponsor]#araxis').on('click', () => navigateExternalTo('https://www.araxis.com/buy/open-source')); + + const navigateExternalTo = (url) => { + window.open(url, "_blank"); + } hljs.initHighlightingOnLoad() @@ -84,6 +117,7 @@ } $(window).on('load', function() { aos_init(); + initTheme(); }); })(jQuery); diff --git a/docs/home/assets/icofont.min.css b/docs/home/assets/icofont.min.css new file mode 100644 index 0000000000..58ff34474c --- /dev/null +++ b/docs/home/assets/icofont.min.css @@ -0,0 +1,7 @@ +/*! +* @package IcoFont +* @version 1.0.1 +* @author IcoFont https://icofont.com +* @copyright Copyright (c) 2015 - 2023 IcoFont +* @license - https://icofont.com/license/ +*/@font-face{font-family:IcoFont;font-weight:400;font-style:Regular;src:url(fonts/icofont.woff2) format("woff2"),url(fonts/icofont.woff) format("woff")}[class*=" icofont-"],[class^=icofont-]{font-family:IcoFont!important;speak:none;font-style:normal;font-weight:400;font-variant:normal;text-transform:none;white-space:nowrap;word-wrap:normal;direction:ltr;line-height:1;-webkit-font-feature-settings:"liga";-webkit-font-smoothing:antialiased}.icofont-simple-up:before{content:"\eab9"}.icofont-xs{font-size:.5em}.icofont-sm{font-size:.75em}.icofont-md{font-size:1.25em}.icofont-lg{font-size:1.5em}.icofont-1x{font-size:1em}.icofont-2x{font-size:2em}.icofont-3x{font-size:3em}.icofont-4x{font-size:4em}.icofont-5x{font-size:5em}.icofont-6x{font-size:6em}.icofont-7x{font-size:7em}.icofont-8x{font-size:8em}.icofont-9x{font-size:9em}.icofont-10x{font-size:10em}.icofont-fw{text-align:center;width:1.25em}.icofont-ul{list-style-type:none;padding-left:0;margin-left:0}.icofont-ul>li{position:relative;line-height:2em}.icofont-ul>li .icofont{display:inline-block;vertical-align:middle}.icofont-border{border:solid .08em #f1f1f1;border-radius:.1em;padding:.2em .25em .15em}.icofont-pull-left{float:left}.icofont-pull-right{float:right}.icofont.icofont-pull-left{margin-right:.3em}.icofont.icofont-pull-right{margin-left:.3em}.icofont-spin{-webkit-animation:icofont-spin 2s infinite linear;animation:icofont-spin 2s infinite linear;display:inline-block}.icofont-pulse{-webkit-animation:icofont-spin 1s infinite steps(8);animation:icofont-spin 1s infinite steps(8);display:inline-block}@-webkit-keyframes icofont-spin{0%{-webkit-transform:rotate(0);transform:rotate(0)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes icofont-spin{0%{-webkit-transform:rotate(0);transform:rotate(0)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}.icofont-rotate-90{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.icofont-rotate-180{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.icofont-rotate-270{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.icofont-flip-horizontal{-webkit-transform:scale(-1,1);transform:scale(-1,1)}.icofont-flip-vertical{-webkit-transform:scale(1,-1);transform:scale(1,-1)}.icofont-flip-horizontal.icofont-flip-vertical{-webkit-transform:scale(-1,-1);transform:scale(-1,-1)}:root .icofont-flip-horizontal,:root .icofont-flip-vertical,:root .icofont-rotate-180,:root .icofont-rotate-270,:root .icofont-rotate-90{-webkit-filter:none;filter:none;display:inline-block}.icofont-inverse{color:#fff}.sr-only{border:0;clip:rect(0,0,0,0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.sr-only-focusable:active,.sr-only-focusable:focus{clip:auto;height:auto;margin:0;overflow:visible;position:static;width:auto} \ No newline at end of file diff --git a/docs/home/assets/img/apple-touch-icon.png b/docs/home/assets/img/apple-touch-icon.png index 447cec2c47..cc7166ba70 100644 Binary files a/docs/home/assets/img/apple-touch-icon.png and b/docs/home/assets/img/apple-touch-icon.png differ diff --git a/docs/home/assets/img/araxis-logo.png b/docs/home/assets/img/araxis-logo.png new file mode 100644 index 0000000000..b25ed12ab8 Binary files /dev/null and b/docs/home/assets/img/araxis-logo.png differ diff --git a/docs/home/assets/img/favicon.png b/docs/home/assets/img/favicon.png index d752fd5d71..de5ad58040 100644 Binary files a/docs/home/assets/img/favicon.png and b/docs/home/assets/img/favicon.png differ diff --git a/docs/home/assets/img/logo.png b/docs/home/assets/img/logo.png deleted file mode 100644 index 2f43cfa72a..0000000000 Binary files a/docs/home/assets/img/logo.png and /dev/null differ diff --git a/docs/home/assets/img/logo.svg b/docs/home/assets/img/logo.svg new file mode 100644 index 0000000000..c7339d2031 --- /dev/null +++ b/docs/home/assets/img/logo.svg @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + JsonApiDotNetCore + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/home/index.html b/docs/home/index.html index 7f01a30e32..a8530ec89a 100644 --- a/docs/home/index.html +++ b/docs/home/index.html @@ -1,148 +1,189 @@ - - - - JsonApiDotNetCore documentation - - - - - - - - - - - - - -
-
+ + + + JsonApiDotNetCore documentation + + + + + + + + + + + + + + + + + + +
+ +
+
+
+
+
+

JsonApiDotNetCore

+

+ A framework for building JSON:API compliant REST APIs using ASP.NET Core and Entity Framework Core. + Includes support for the Atomic Operations extension. +

+ Read more + Getting started + Contribute on GitHub +
+
+ project logo +
+
+
+
+
+
+
+
+
+ people working at desk +
+
+

Objectives

+

+ The goal of this library is to simplify the development of APIs that leverage the full range of features + provided by the JSON:API specification. + You just need to focus on defining the resources and implementing your custom business logic. +

-
-

JsonApiDotNetCore

-

A framework for building JSON:API compliant REST APIs using .NET Core and Entity Framework Core. Includes support for Atomic Operations.

- Read more - Getting started - Contribute on GitHub -
-
- project logo -
+
+ +

Eliminate boilerplate

+

+ The ultimate goal of this library is to eliminate as much boilerplate as possible by offering out-of-the-box features, such as sorting, filtering, pagination, sparse fieldset selection, and side-loading related resources. +

+
+
+ +

Extensibility

+

This library has been designed around dependency injection, making extensibility incredibly easy.

+
-
-
-
-
-
-
-
- people working at desk -
-
-

Objectives

-

- The goal of this library is to simplify the development of APIs that leverage the full range of features provided by the JSON:API specification. - You just need to focus on defining the resources and implementing your custom business logic. -

-
-
- -

Eliminate boilerplate

-

We strive to eliminate as much boilerplate as possible by offering out-of-the-box features such as sorting, filtering and pagination.

-
-
- -

Extensibility

-

This library has been designed around dependency injection, making extensibility incredibly easy.

-
-
-
-
+
+
+ +
+
+
+
+

Features

+

The following features are supported, from HTTP all the way down to the database

+
+
+
+
+
+

Filtering

+

Perform compound filtering using the filter query string parameter

-
-
-
-
-

Features

-

The following features are supported, from HTTP all the way down to the database

-
-
-
-
-
-

Filtering

-

Perform compound filtering using the filter query string parameter

-
-
-
-
-
-

Sorting

-

Order resources on one or multiple attributes using the sort query string parameter

-
-
- -
-
-
-

Sparse fieldset selection

-

Get only the data that you need using the fields query string parameter

-
-
-
-
-
-
-
-

Relationship inclusion

-

Side-load related resources of nested relationships using the include query string parameter

-
-
-
-
-
-

Security

-

Configure permissions, such as view/create/change/sort/filter of attributes and relationships

-
-
-
-
-
-

Validation

-

Validate incoming requests using built-in ASP.NET Core ModelState validation, which works seamlessly with partial updates

-
-
-
-
-
-

Customizable

-

Use various extensibility points to intercept and run custom code, besides just model annotations

-
-
-
-
-
-
-
-
-

Example usage

-

Expose resources with attributes and relationships

+
+
+
+
+

Sorting

+

Order resources on multiple attributes using the sort query string parameter

-
-
-
-
-

Resource

-
-#nullable enable
+          
+ +
+
+
+

Sparse fieldset selection

+

Get only the data that you need using the fields query string parameter

+
+
+
+
+
+
+
+

Relationship inclusion

+

Side-load related resources of nested relationships using the include query string parameter

+
+
+
+
+
+

Security

+

Configure permissions, such as viewing, creating, modifying, sorting and filtering of attributes and relationships

+
+
+
+
+
+

Validation

+

Validate incoming requests using built-in ASP.NET Model Validation, which works seamlessly with partial updates

+
+
+
+
+
+

Customizable

+

Use various extensibility points to intercept and run custom code, besides just model annotations

+
+
+
+
+
+
+
+
+

Example usage

+

Expose resources with attributes and relationships

+
+
+
+
+
+

Resource

+
+#nullable enable
 
 public class Article : Identifiable<long>
 {
@@ -172,38 +213,33 @@ 

Resource

[HasMany] public ICollection<Tag> Tags { get; set; } = new HashSet<Tag>(); }
-
-
-
+
-
-
-
-
-

Request

-
-
-GET /articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields[articles]=title,summary&include=author HTTP/1.1
-
-                     
-
-
+
+
+
+
+
+
+

Request

+
GET /articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields[articles]=title,summary&include=author HTTP/1.1
-
-
-
-
-

Response

-
-
-{
+          
+
+
+
+
+
+

Response

+
+{
   "meta": {
     "totalResources": 1
   },
   "links": {
-    "self": "/articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields%5Barticles%5D=title,summary&include=author",
-    "first": "/articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields%5Barticles%5D=title,summary&include=author",
-    "last": "/articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields%5Barticles%5D=title,summary&include=author"
+    "self": "/articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields%5Barticles%5D=title,summary&include=author",
+    "first": "/articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields%5Barticles%5D=title,summary&include=author",
+    "last": "/articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields%5Barticles%5D=title,summary&include=author"
   },
   "data": [
     {
@@ -252,31 +288,54 @@ 

Response

] }
-
-
-
+ +
+
+
+
+
+
+
+
+

Sponsors

+
+
+
+
+
+ JetBrains Logo +
-
-
-
-
- - - - - - - - - - + + + +
+ +
+ + + + + + + + + + diff --git a/docs/internals/queries.md b/docs/internals/queries.md index 46005f489c..198a1659a2 100644 --- a/docs/internals/queries.md +++ b/docs/internals/queries.md @@ -4,8 +4,9 @@ _since v4.0_ The query pipeline roughly looks like this: -``` -HTTP --[ASP.NET]--> QueryString --[JADNC:QueryStringParameterReader]--> QueryExpression[] --[JADNC:ResourceService]--> QueryLayer --[JADNC:Repository]--> IQueryable --[Entity Framework Core]--> SQL +```mermaid +flowchart TB +A[HTTP] -->|ASP.NET| B(QueryString) -->|JADNC:QueryStringParameterReader| C("QueryExpression[]") -->|JADNC:ResourceService| D(QueryLayer) -->|JADNC:Repository| E(IQueryable) -->|Entity Framework Core| F[(SQL)] ``` Processing a request involves the following steps: @@ -22,25 +23,25 @@ Processing a request involves the following steps: - `JsonApiResourceService` contains no more usage of `IQueryable`. - `EntityFrameworkCoreRepository` delegates to `QueryableBuilder` to transform the `QueryLayer` tree into `IQueryable` expression trees. `QueryBuilder` depends on `QueryClauseBuilder` implementations that visit the tree nodes, transforming them to `System.Linq.Expression` equivalents. - The `IQueryable` expression trees are executed by Entity Framework Core, which produces SQL statements out of them. + The `IQueryable` expression trees are passed to Entity Framework Core, which produces SQL statements out of them. - `JsonApiWriter` transforms resource objects into json response. # Example To get a sense of what this all looks like, let's look at an example query string: ``` -/api/v1/blogs? - include=owner,articles.revisions.author& - filter=has(articles)& - sort=count(articles)& +/api/blogs? + include=owner,posts.comments.author& + filter=has(posts)& + sort=count(posts)& page[number]=3& fields[blogs]=title& - filter[articles]=and(not(equals(author.firstName,null)),has(revisions))& - sort[articles]=author.lastName& - fields[articles]=url& - filter[articles.revisions]=and(greaterThan(publishTime,'2001-01-01'),startsWith(author.firstName,'J'))& - sort[articles.revisions]=-publishTime,author.lastName& - fields[revisions]=publishTime + filter[posts]=and(not(equals(author.userName,null)),has(comments))& + sort[posts]=author.displayName& + fields[blogPosts]=url& + filter[posts.comments]=and(greaterThan(createdAt,'2001-01-01Z'),startsWith(author.userName,'J'))& + sort[posts.comments]=-createdAt,author.displayName& + fields[comments]=createdAt ``` After parsing, the set of scoped expressions is transformed into the following tree by `QueryLayerComposer`: @@ -48,40 +49,50 @@ After parsing, the set of scoped expressions is transformed into the following t ``` QueryLayer { - Include: owner,articles.revisions - Filter: has(articles) - Sort: count(articles) + Include: owner,posts.comments.author + Filter: has(posts) + Sort: count(posts) Pagination: Page number: 3, size: 5 - Projection + Selection { - title - id - owner: QueryLayer - { - Sort: id - Pagination: Page number: 1, size: 5 - } - articles: QueryLayer
+ FieldSelectors { - Filter: and(not(equals(author.firstName,null)),has(revisions)) - Sort: author.lastName - Pagination: Page number: 1, size: 5 - Projection + title + id + posts: QueryLayer { - url - id - revisions: QueryLayer + Filter: and(not(equals(author.userName,null)),has(comments)) + Sort: author.displayName + Pagination: Page number: 1, size: 5 + Selection { - Filter: and(greaterThan(publishTime,'2001-01-01'),startsWith(author.firstName,'J')) - Sort: -publishTime,author.lastName - Pagination: Page number: 1, size: 5 - Projection + FieldSelectors { - publishTime + url id + comments: QueryLayer + { + Filter: and(greaterThan(createdAt,'2001-01-01'),startsWith(author.userName,'J')) + Sort: -createdAt,author.displayName + Pagination: Page number: 1, size: 5 + Selection + { + FieldSelectors + { + createdAt + id + author: QueryLayer + { + } + } + } + } } } } + owner: QueryLayer + { + } } } } @@ -90,36 +101,86 @@ QueryLayer Next, the repository translates this into a LINQ query that the following C# code would represent: ```c# -var query = dbContext.Blogs +IQueryable query = dbContext.Blogs + .Include("Posts.Comments.Author") .Include("Owner") - .Include("Articles.Revisions") - .Where(blog => blog.Articles.Any()) - .OrderBy(blog => blog.Articles.Count) + .Where(blog => blog.Posts.Any()) + .OrderBy(blog => blog.Posts.Count) .Skip(10) .Take(5) .Select(blog => new Blog { Title = blog.Title, Id = blog.Id, - Owner = blog.Owner, - Articles = new List
(blog.Articles - .Where(article => article.Author.FirstName != null && article.Revisions.Any()) - .OrderBy(article => article.Author.LastName) + Posts = blog.Posts + .Where(blogPost => blogPost.Author.UserName != null && blogPost.Comments.Any()) + .OrderBy(blogPost => blogPost.Author.DisplayName) .Take(5) - .Select(article => new Article + .Select(blogPost => new BlogPost { - Url = article.Url, - Id = article.Id, - Revisions = new HashSet(article.Revisions - .Where(revision => revision.PublishTime > DateTime.Parse("2001-01-01") && revision.Author.FirstName.StartsWith("J")) - .OrderByDescending(revision => revision.PublishTime) - .ThenBy(revision => revision.Author.LastName) + Url = blogPost.Url, + Id = blogPost.Id, + Comments = blogPost.Comments + .Where(comment => comment.CreatedAt > DateTime.Parse("2001-01-01Z") && + comment.Author.UserName.StartsWith("J")) + .OrderByDescending(comment => comment.CreatedAt) + .ThenBy(comment => comment.Author.DisplayName) .Take(5) - .Select(revision => new Revision + .Select(comment => new Comment { - PublishTime = revision.PublishTime, - Id = revision.Id - })) - })) + CreatedAt = comment.CreatedAt, + Id = comment.Id, + Author = comment.Author + }).ToHashSet() + }).ToList(), + Owner = blog.Owner }); ``` + +The LINQ query gets translated by Entity Framework Core into the following SQL: + +```sql +SELECT t."Title", t."Id", a."Id", t2."Url", t2."Id", t2."Id0", t2."CreatedAt", t2."Id1", t2."Id00", t2."DateOfBirth", t2."DisplayName", t2."EmailAddress", t2."Password", t2."PersonId", t2."PreferencesId", t2."UserName", a."DateOfBirth", a."DisplayName", a."EmailAddress", a."Password", a."PersonId", a."PreferencesId", a."UserName" +FROM ( + SELECT b."Id", b."OwnerId", b."Title", ( + SELECT COUNT(*)::INT + FROM "Posts" AS p0 + WHERE b."Id" = p0."ParentId") AS c + FROM "Blogs" AS b + WHERE EXISTS ( + SELECT 1 + FROM "Posts" AS p + WHERE b."Id" = p."ParentId") + ORDER BY ( + SELECT COUNT(*)::INT + FROM "Posts" AS p0 + WHERE b."Id" = p0."ParentId") + LIMIT @__Create_Item1_1 OFFSET @__Create_Item1_0 +) AS t +LEFT JOIN "Accounts" AS a ON t."OwnerId" = a."Id" +LEFT JOIN LATERAL ( + SELECT t0."Url", t0."Id", t0."Id0", t1."CreatedAt", t1."Id" AS "Id1", t1."Id0" AS "Id00", t1."DateOfBirth", t1."DisplayName", t1."EmailAddress", t1."Password", t1."PersonId", t1."PreferencesId", t1."UserName", t0."DisplayName" AS "DisplayName0", t1."ParentId" + FROM ( + SELECT p1."Url", p1."Id", a0."Id" AS "Id0", a0."DisplayName" + FROM "Posts" AS p1 + LEFT JOIN "Accounts" AS a0 ON p1."AuthorId" = a0."Id" + WHERE (t."Id" = p1."ParentId") AND (((a0."UserName" IS NOT NULL)) AND EXISTS ( + SELECT 1 + FROM "Comments" AS c + WHERE p1."Id" = c."ParentId")) + ORDER BY a0."DisplayName" + LIMIT @__Create_Item1_1 + ) AS t0 + LEFT JOIN ( + SELECT t3."CreatedAt", t3."Id", t3."Id0", t3."DateOfBirth", t3."DisplayName", t3."EmailAddress", t3."Password", t3."PersonId", t3."PreferencesId", t3."UserName", t3."ParentId" + FROM ( + SELECT c0."CreatedAt", c0."Id", a1."Id" AS "Id0", a1."DateOfBirth", a1."DisplayName", a1."EmailAddress", a1."Password", a1."PersonId", a1."PreferencesId", a1."UserName", c0."ParentId", ROW_NUMBER() OVER(PARTITION BY c0."ParentId" ORDER BY c0."CreatedAt" DESC, a1."DisplayName") AS row + FROM "Comments" AS c0 + LEFT JOIN "Accounts" AS a1 ON c0."AuthorId" = a1."Id" + WHERE (c0."CreatedAt" > @__Create_Item1_2) AND ((@__Create_Item1_3 = '') OR (((a1."UserName" IS NOT NULL)) AND ((a1."UserName" LIKE @__Create_Item1_3 || '%' ESCAPE '') AND (left(a1."UserName", length(@__Create_Item1_3))::text = @__Create_Item1_3::text)))) + ) AS t3 + WHERE t3.row <= @__Create_Item1_1 + ) AS t1 ON t0."Id" = t1."ParentId" +) AS t2 ON TRUE +ORDER BY t.c, t."Id", a."Id", t2."DisplayName0", t2."Id", t2."Id0", t2."ParentId", t2."CreatedAt" DESC, t2."DisplayName", t2."Id1" +``` diff --git a/docs/internals/toc.md b/docs/internals/toc.md deleted file mode 100644 index 0533dc5272..0000000000 --- a/docs/internals/toc.md +++ /dev/null @@ -1 +0,0 @@ -# [Queries](queries.md) diff --git a/docs/internals/toc.yml b/docs/internals/toc.yml new file mode 100644 index 0000000000..adb35afc58 --- /dev/null +++ b/docs/internals/toc.yml @@ -0,0 +1,2 @@ +- name: Queries + href: queries.md diff --git a/docs/request-examples/001_GET_Books.ps1 b/docs/request-examples/001_GET_Books.ps1 index 559bbfb4d5..f89f9cdd4f 100644 --- a/docs/request-examples/001_GET_Books.ps1 +++ b/docs/request-examples/001_GET_Books.ps1 @@ -1 +1,3 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/books diff --git a/docs/request-examples/002_GET_Person-by-ID.ps1 b/docs/request-examples/002_GET_Person-by-ID.ps1 index d565c7cf53..77851b3116 100644 --- a/docs/request-examples/002_GET_Person-by-ID.ps1 +++ b/docs/request-examples/002_GET_Person-by-ID.ps1 @@ -1 +1,3 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/people/1 diff --git a/docs/request-examples/003_GET_Books-including-Author.ps1 b/docs/request-examples/003_GET_Books-including-Author.ps1 index 33f5dcd487..6dd71f1f4e 100644 --- a/docs/request-examples/003_GET_Books-including-Author.ps1 +++ b/docs/request-examples/003_GET_Books-including-Author.ps1 @@ -1 +1,3 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/books?include=author diff --git a/docs/request-examples/004_GET_Books-PublishYear.ps1 b/docs/request-examples/004_GET_Books-PublishYear.ps1 index a08cb7e6a0..d07cc7bc1a 100644 --- a/docs/request-examples/004_GET_Books-PublishYear.ps1 +++ b/docs/request-examples/004_GET_Books-PublishYear.ps1 @@ -1 +1,3 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/books?fields%5Bbooks%5D=publishYear diff --git a/docs/request-examples/005_GET_People-Filter_Partial.ps1 b/docs/request-examples/005_GET_People-Filter_Partial.ps1 index 2e2339f76c..092a54ef1e 100644 --- a/docs/request-examples/005_GET_People-Filter_Partial.ps1 +++ b/docs/request-examples/005_GET_People-Filter_Partial.ps1 @@ -1 +1,3 @@ +#Requires -Version 7.3 + curl -s -f "http://localhost:14141/api/people?filter=contains(name,'Shell')" diff --git a/docs/request-examples/006_GET_Books-sorted-by-PublishYear-descending.ps1 b/docs/request-examples/006_GET_Books-sorted-by-PublishYear-descending.ps1 index 309ad6dcc6..fd96cb3c86 100644 --- a/docs/request-examples/006_GET_Books-sorted-by-PublishYear-descending.ps1 +++ b/docs/request-examples/006_GET_Books-sorted-by-PublishYear-descending.ps1 @@ -1 +1,3 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/books?sort=-publishYear diff --git a/docs/request-examples/007_GET_Books-paginated.ps1 b/docs/request-examples/007_GET_Books-paginated.ps1 index c088163a52..a744886801 100644 --- a/docs/request-examples/007_GET_Books-paginated.ps1 +++ b/docs/request-examples/007_GET_Books-paginated.ps1 @@ -1 +1,3 @@ +#Requires -Version 7.3 + curl -s -f "http://localhost:14141/api/books?page%5Bsize%5D=1&page%5Bnumber%5D=2" diff --git a/docs/request-examples/010_CREATE_Person.ps1 b/docs/request-examples/010_CREATE_Person.ps1 index 1a76f0cad1..e8f95020cd 100644 --- a/docs/request-examples/010_CREATE_Person.ps1 +++ b/docs/request-examples/010_CREATE_Person.ps1 @@ -1,10 +1,12 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/people ` -H "Content-Type: application/vnd.api+json" ` -d '{ - \"data\": { - \"type\": \"people\", - \"attributes\": { - \"name\": \"Alice\" + "data": { + "type": "people", + "attributes": { + "name": "Alice" } } }' diff --git a/docs/request-examples/011_CREATE_Book-with-Author.ps1 b/docs/request-examples/011_CREATE_Book-with-Author.ps1 index bf839f5a85..0737689408 100644 --- a/docs/request-examples/011_CREATE_Book-with-Author.ps1 +++ b/docs/request-examples/011_CREATE_Book-with-Author.ps1 @@ -1,17 +1,19 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/books ` -H "Content-Type: application/vnd.api+json" ` -d '{ - \"data\": { - \"type\": \"books\", - \"attributes\": { - \"title\": \"Valperga\", - \"publishYear\": 1823 + "data": { + "type": "books", + "attributes": { + "title": "Valperga", + "publishYear": 1823 }, - \"relationships\": { - \"author\": { - \"data\": { - \"type\": \"people\", - \"id\": \"1\" + "relationships": { + "author": { + "data": { + "type": "people", + "id": "1" } } } diff --git a/docs/request-examples/012_PATCH_Book.ps1 b/docs/request-examples/012_PATCH_Book.ps1 index d704c8c8c8..61ea6bee76 100644 --- a/docs/request-examples/012_PATCH_Book.ps1 +++ b/docs/request-examples/012_PATCH_Book.ps1 @@ -1,12 +1,14 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/books/1 ` -H "Content-Type: application/vnd.api+json" ` -X PATCH ` -d '{ - \"data\": { - \"type\": \"books\", - \"id\": \"1\", - \"attributes\": { - \"publishYear\": 1820 + "data": { + "type": "books", + "id": "1", + "attributes": { + "publishYear": 1820 } } }' diff --git a/docs/request-examples/013_DELETE_Book.ps1 b/docs/request-examples/013_DELETE_Book.ps1 index d5fdd8e103..bbd7ba7445 100644 --- a/docs/request-examples/013_DELETE_Book.ps1 +++ b/docs/request-examples/013_DELETE_Book.ps1 @@ -1,2 +1,4 @@ +#Requires -Version 7.3 + curl -s -f http://localhost:14141/api/books/1 ` -X DELETE diff --git a/docs/request-examples/README.md b/docs/request-examples/README.md index eb95ea4656..5a2911f5cb 100644 --- a/docs/request-examples/README.md +++ b/docs/request-examples/README.md @@ -2,18 +2,20 @@ To update these requests: -1. Add a PowerShell (.ps1) script prefixed by a number that is used to determine the order the scripts are executed. The script should execute a request and output the response. Example: -``` -curl -s http://localhost:14141/api/books -``` +1. Add a PowerShell (`.ps1`) script prefixed by a number that is used to determine the order the scripts are executed. + The script should execute a request and output the response. For example: + ``` + curl -s http://localhost:14141/api/books + ``` -2. Add the example to `index.md`. Example: -``` -### Get with relationship +2. Add the example to `index.md`. For example: + ``` + ### Get with relationship -[!code-ps[REQUEST](003_GET_Books-including-Author.ps1)] -[!code-json[RESPONSE](003_GET_Books-including-Author_Response.json)] -``` + [!code-ps[REQUEST](003_GET_Books-including-Author.ps1)] + [!code-json[RESPONSE](003_GET_Books-including-Author_Response.json)] + ``` -3. Run `pwsh ../generate-examples.ps1` -4. Verify the results by running `pwsh ../build-dev.ps1` +3. Run `pwsh ../generate-examples.ps1` to execute the request. + +4. Run `pwsh ../build-dev.ps1` to view the output on the website. diff --git a/docs/request-examples/index.md b/docs/request-examples/index.md index 4d82e95854..89c7043450 100644 --- a/docs/request-examples/index.md +++ b/docs/request-examples/index.md @@ -1,12 +1,28 @@ -# Example requests +# Example projects + +Runnable example projects can be found [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/src/Examples): + +- GettingStarted: A simple project with minimal configuration to develop a runnable project in minutes. +- JsonApiDotNetCoreExample: Showcases commonly-used features, such as resource definitions, atomic operations, and OpenAPI. + - OpenApiNSwagClientExample: Uses [NSwag](https://github.com/RicoSuter/NSwag) to generate a typed OpenAPI client. + - OpenApiKiotaClientExample: Uses [Kiota](https://learn.microsoft.com/en-us/openapi/kiota/) to generate a typed OpenAPI client. +- MultiDbContextExample: Shows how to use multiple `DbContext` classes, for connecting to multiple databases. +- DatabasePerTenantExample: Uses a different database per tenant. See [here](~/usage/advanced/multi-tenancy.md) for using multiple tenants in the same database. +- NoEntityFrameworkExample: Uses a read-only in-memory repository, instead of a real database. +- DapperExample: Uses [Dapper](https://github.com/DapperLib/Dapper) to execute SQL queries. +- ReportsExample: Uses a resource service that returns aggregated data. -These requests have been generated against the "GettingStarted" application and are updated on every deployment. +> [!NOTE] +> The example projects only cover highly-requested features. More advanced use cases can be found [here](~/usage/advanced/index.md). + +# Example requests -All of these requests have been created using out-of-the-box features. +The following requests are automatically generated against the "GettingStarted" application on every deployment. -_Note that cURL requires "[" and "]" in URLs to be escaped._ +> [!NOTE] +> curl requires "[" and "]" in URLs to be escaped. -# Reading data +## Reading data ### Get all @@ -43,7 +59,7 @@ _Note that cURL requires "[" and "]" in URLs to be escaped._ [!code-ps[REQUEST](007_GET_Books-paginated.ps1)] [!code-json[RESPONSE](007_GET_Books-paginated_Response.json)] -# Writing data +## Writing data ### Create resource diff --git a/docs/template/public/main.css b/docs/template/public/main.css new file mode 100644 index 0000000000..a20926d93f --- /dev/null +++ b/docs/template/public/main.css @@ -0,0 +1,6 @@ +/* From https://github.com/dotnet/docfx/discussions/9644 */ + +body { + --bs-link-color-rgb: 66, 184, 131 !important; + --bs-link-hover-color-rgb: 64, 180, 128 !important; +} diff --git a/docs/template/public/main.js b/docs/template/public/main.js new file mode 100644 index 0000000000..be4428bed6 --- /dev/null +++ b/docs/template/public/main.js @@ -0,0 +1,11 @@ +// From https://github.com/dotnet/docfx/discussions/9644 + +export default { + iconLinks: [ + { + icon: 'github', + href: 'https://github.com/json-api-dotnet/JsonApiDotNetCore', + title: 'GitHub' + } + ] +} diff --git a/docs/toc.yml b/docs/toc.yml index e9165998e5..29f786ca4a 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -1,17 +1,12 @@ - name: Getting Started - href: getting-started/ - + href: getting-started/index.md - name: Usage href: usage/ - - name: API href: api/ - homepage: api/index.md - + topicHref: api/index.md - name: Examples - href: request-examples/ - homepage: request-examples/index.md - + href: request-examples/index.md - name: Internals href: internals/ - homepage: internals/index.md + topicHref: internals/index.md diff --git a/docs/usage/advanced/alternate-routes.md b/docs/usage/advanced/alternate-routes.md new file mode 100644 index 0000000000..a860a61fa7 --- /dev/null +++ b/docs/usage/advanced/alternate-routes.md @@ -0,0 +1,8 @@ +# Alternate Routes + +The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes) shows how the default JSON:API routes can be changed. + +The classes `TownsController` and `CiviliansController`: +- Are decorated with `[DisableRoutingConvention]` to turn off the default JSON:API routing convention. +- Are decorated with the ASP.NET `[Route]` attribute to specify at which route the controller is exposed. +- Are augmented with non-standard JSON:API action methods, whose `[HttpGet]` attributes specify a custom route. diff --git a/docs/usage/advanced/archiving.md b/docs/usage/advanced/archiving.md new file mode 100644 index 0000000000..3892877a52 --- /dev/null +++ b/docs/usage/advanced/archiving.md @@ -0,0 +1,14 @@ +# Archiving + +The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving) demonstrates how to implement archived resources. + +> [!TIP] +> This scenario is comparable with [Soft Deletion](~/usage/advanced/soft-deletion.md). +> The difference is that archived resources are accessible to JSON:API clients, whereas soft-deleted resources _never_ are. + +- Archived resources can be fetched by ID, but don't show up in searches by default. +- Resources can only be created in a non-archived state and then archived/unarchived using a PATCH resource request. +- The archive date is stored in the database, but cannot be modified through JSON:API. +- To delete a resource, it must be archived first. + +This feature is implemented using a custom resource definition. It intercepts write operations and recursively scans incoming filters. diff --git a/docs/usage/advanced/auth-scopes.md b/docs/usage/advanced/auth-scopes.md new file mode 100644 index 0000000000..e37cb1b6ae --- /dev/null +++ b/docs/usage/advanced/auth-scopes.md @@ -0,0 +1,10 @@ +# Authorization Scopes + +The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes) shows how scope-based authorization can be used. + +- For simplicity, this code assumes the granted scopes are passed in a plain-text HTTP header. A more realistic use case would be to obtain the scopes from an OAuth token. +- The HTTP header lists which resource types can be read from and/or written to. +- An [ASP.NET Action Filter](https://learn.microsoft.com/aspnet/core/mvc/controllers/filters) validates incoming JSON:API resource/relationship requests. + - The incoming request path is validated against the permitted read/write permissions per resource type. + - The resource types used in query string parameters are validated against the permitted set of resource types. +- A customized operations controller verifies that all incoming operations are allowed. diff --git a/docs/usage/advanced/blobs.md b/docs/usage/advanced/blobs.md new file mode 100644 index 0000000000..d3d4525c66 --- /dev/null +++ b/docs/usage/advanced/blobs.md @@ -0,0 +1,9 @@ +# BLOBs + +The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/Blobs) shows how Binary Large Objects (BLOBs) can be used. + +- The `ImageContainer` resource type contains nullable and non-nullable `byte[]` properties. +- BLOBs are queried and persisted using Entity Framework Core. +- The BLOB data is returned as a base-64 encoded string in the JSON response. + +Blobs are handled automatically; there's no need for custom code. diff --git a/docs/usage/advanced/composite-keys.md b/docs/usage/advanced/composite-keys.md new file mode 100644 index 0000000000..768a22a190 --- /dev/null +++ b/docs/usage/advanced/composite-keys.md @@ -0,0 +1,8 @@ +# Composite Keys + +The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys) shows how database tables with composite keys can be used. + +- The `DbContext` configures `Car` to have a composite primary key consisting of the `RegionId` and `LicensePlate` columns. +- The `Car.Id` property is overridden to provide a unique ID for JSON:API. It is marked with `[NotMapped]`, meaning no `Id` column exists in the database table. +- The `Engine` and `Dealership` resource types define relationships that generate composite foreign keys in the database. +- A custom resource repository is used to rewrite IDs from filter/sort query string parameters into `RegionId` and `LicensePlate` lookups. diff --git a/docs/usage/advanced/content-negotiation.md b/docs/usage/advanced/content-negotiation.md new file mode 100644 index 0000000000..980b2e0b65 --- /dev/null +++ b/docs/usage/advanced/content-negotiation.md @@ -0,0 +1,15 @@ +# Content Negotiation + +The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation) demonstrates how content negotiation in JSON:API works. + +Additionally, the code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions) provides +a custom "server-time" JSON:API extension that returns the local or UTC server time in top-level `meta`. +- This extension can be used in the `Accept` and `Content-Type` HTTP headers. +- In a request body, the optional `useLocalTime` property in top-level `meta` indicates whether to return the local or UTC time. + +This feature is implemented using the following extensibility points: + +- At startup, the "server-time" extension is added in `JsonApiOptions`, which permits clients to use it. +- A custom `JsonApiContentNegotiator` chooses which extensions are active for an incoming request, taking the "server-time" extension into account. +- A custom `IDocumentAdapter` captures the incoming request body, providing access to the `useLocalTime` property in `meta`. +- A custom `IResponseMeta` adds the server time to the response, depending on the activated extensions in `IJsonApiRequest` and the captured request body. diff --git a/docs/usage/advanced/eager-loading.md b/docs/usage/advanced/eager-loading.md new file mode 100644 index 0000000000..72e401c4f0 --- /dev/null +++ b/docs/usage/advanced/eager-loading.md @@ -0,0 +1,12 @@ +# Eager Loading Related Resources + +The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading) uses the `[EagerLoad]` attribute to facilitate calculated properties that depend on related resources. +The related resources are fetched from the database, but not returned to the client unless explicitly requested using the `include` query string parameter. + +- The `Street` resource type uses `EagerLoad` on its `Buildings` to-many relationship because its `DoorTotalCount` calculated property depends on it. +- The `Building` resource type uses `EagerLoad` on its `Windows` to-many relationship because its `WindowCount` calculated property depends on it. +- The `Building` resource type uses `EagerLoad` on its `PrimaryDoor` to-one required relationship because its `PrimaryDoorColor` calculated property depends on it. + - Because this is a required relationship, special handling occurs in `Building`, `BuildingRepository`, and `BuildingDefinition`. +- The `Building` resource type uses `EagerLoad` on its `SecondaryDoor` to-one optional relationship because its `SecondaryDoorColor` calculated property depends on it. + +As can be seen from the usages above, a chain of `EagerLoad` attributes can result in fetching a chain of related resources from the database. diff --git a/docs/usage/advanced/error-handling.md b/docs/usage/advanced/error-handling.md new file mode 100644 index 0000000000..c53b3f2669 --- /dev/null +++ b/docs/usage/advanced/error-handling.md @@ -0,0 +1,13 @@ +# Error Handling + +The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling) shows how to customize error handling. + +A user-defined exception, `ConsumerArticleIsNoLongerAvailableException`, is thrown from a resource service to demonstrate handling it. +Note that this exception can be thrown from anywhere during request execution; a resource service is just used here for simplicity. + +To handle the user-defined exception, `AlternateExceptionHandler` inherits from `ExceptionHandler` to: +- Customize the JSON:API error response by adding a `meta` entry when `ConsumerArticleIsNoLongerAvailableException` is thrown. +- Indicate that `ConsumerArticleIsNoLongerAvailableException` must be logged at the Warning level. + +Additionally, the `ThrowingArticle.Status` property throws an `InvalidOperationException`. +This triggers the default error handling because `AlternateExceptionHandler` delegates to its base class. diff --git a/docs/usage/advanced/hosting-iis.md b/docs/usage/advanced/hosting-iis.md new file mode 100644 index 0000000000..f452adaeec --- /dev/null +++ b/docs/usage/advanced/hosting-iis.md @@ -0,0 +1,7 @@ +# Hosting in Internet Information Services (IIS) + +The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS) calls [UsePathBase](https://learn.microsoft.com/dotnet/api/microsoft.aspnetcore.builder.usepathbaseextensions.usepathbase) to simulate hosting in IIS. +For details on how `UsePathBase` works, see [Understanding PathBase in ASP.NET Core](https://andrewlock.net/understanding-pathbase-in-aspnetcore/). + +- At startup, the line `app.UsePathBase("/iis-application-virtual-directory")` configures ASP.NET to use the base path. +- `PaintingsController` uses a custom route to demonstrate that both features can be used together. diff --git a/docs/usage/advanced/id-obfuscation.md b/docs/usage/advanced/id-obfuscation.md new file mode 100644 index 0000000000..4012238c29 --- /dev/null +++ b/docs/usage/advanced/id-obfuscation.md @@ -0,0 +1,16 @@ +# ID Obfuscation + +The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation) shows how to use obfuscated IDs. +They are typically used to prevent clients from guessing primary key values. + +All IDs sent by clients are transparently de-obfuscated into internal numeric values before accessing the database. +Numeric IDs returned from the database are obfuscated before they are sent to the client. + +> [!NOTE] +> An alternate solution is to use GUIDs instead of numeric primary keys in the database. + +ID obfuscation is achieved using the following extensibility points: + +- For simplicity, `HexadecimalCodec` is used to obfuscate numeric IDs to a hexadecimal format. A more realistic use case would be to use a symmetric crypto algorithm. +- `ObfuscatedIdentifiable` acts as the base class for resource types, handling the obfuscation and de-obfuscation of IDs. +- `ObfuscatedIdentifiableController` acts as the base class for controllers. It inherits from `BaseJsonApiController`, changing the `id` parameter in action methods to type `string`. diff --git a/docs/usage/advanced/index.md b/docs/usage/advanced/index.md new file mode 100644 index 0000000000..6bf9841dbe --- /dev/null +++ b/docs/usage/advanced/index.md @@ -0,0 +1,19 @@ +# Advanced JSON:API features + +This topic goes beyond the basics of what's possible with JsonApiDotNetCore. + +Advanced use cases are provided in the form of integration tests [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests). +This ensures they don't break during development of the framework. + +Each directory typically contains: + +- A set of resource types. +- A `DbContext` class to register the resource types. +- Fakers to generate deterministic test data. +- Test classes that assert the feature works as expected. + - Entities are inserted into a randomly named PostgreSQL database. + - An HTTP request is sent. + - The returned response is asserted on. + - If applicable, the changes are fetched from the database and asserted on. + +To run/debug the integration tests, follow the steps in [README.md](https://github.com/json-api-dotnet/JsonApiDotNetCore#build-from-source). diff --git a/docs/usage/advanced/links.md b/docs/usage/advanced/links.md new file mode 100644 index 0000000000..d26be87563 --- /dev/null +++ b/docs/usage/advanced/links.md @@ -0,0 +1,19 @@ +# Links + +The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/Links) shows various ways to configure which links are returned, and how they appear in responses. + +> [!TIP] +> By default, absolute links are returned. To return relative links, set [JsonApiOptions.UseRelativeLinks](~/usage/options.md#relative-links) at startup. + +> [!TIP] +> To add a global prefix to all routes, set `JsonApiOptions.Namespace` at startup. + +Which links to render can be configured globally in options, then overridden per resource type, and then overridden per relationship. + +- The `PhotoLocation` resource type turns off `TopLevelLinks` and `ResourceLinks`, and sets `RelationshipLinks` to `Related`. +- The `PhotoLocation.Album` relationship turns off all links for this relationship. + +The various tests set `JsonApiOptions.Namespace` and `JsonApiOptions.UseRelativeLinks` to verify that the proper links are rendered. +This can't be set in the tests directly for technical reasons, so they use different `Startup` classes to control this. + +Link rendering is fully controlled using attributes on your models. No further code is needed. diff --git a/docs/usage/advanced/microservices.md b/docs/usage/advanced/microservices.md new file mode 100644 index 0000000000..88e9cb08b9 --- /dev/null +++ b/docs/usage/advanced/microservices.md @@ -0,0 +1,22 @@ +# Microservices + +The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices) shows patterns commonly used in microservices architecture: + +- [Fire-and-forget](https://microservices.io/patterns/communication-style/messaging.html): Outgoing messages are sent to an external queue, without waiting for their processing to start. While this is the simplest solution, it is not very reliable when errors occur. +- [Transactional Outbox Pattern](https://microservices.io/patterns/data/transactional-outbox.html): Outgoing messages are saved to a queue table within the same database transaction. A background job (omitted in this example) polls the queue table and sends the messages to an external queue. + +> [!TIP] +> Potential external queue systems you could use are [RabbitMQ](https://www.rabbitmq.com/), [MassTransit](https://masstransit.io/), +> [Azure Service Bus](https://learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-messaging-overview) and [Apache Kafka](https://kafka.apache.org/). However, this is beyond the scope of this topic. + +The `Messages` directory lists the functional messages that are created from incoming JSON:API requests, which are typically processed by an external system that handles messages from the queue. +Each message has a unique ID and type, and is versioned to support gradual deployments. +Example payloads of messages are: user created, user login name changed, user moved to group, group created, group renamed, etc. + +The abstract types `MessagingGroupDefinition` and `MessagingUserDefinition` are resource definitions that contain code shared by both patterns. They inspect the incoming request and produce one or more functional messages from it. +The pattern-specific derived types inject their `DbContext`, which is used to query for additional information when determining what is being changed. + +> [!NOTE] +> Because networks are inherently unreliable, systems that consume messages from an external queue should be [idempotent](https://microservices.io/patterns/communication-style/idempotent-consumer.html). +> Several years ago, a [prototype](https://github.com/json-api-dotnet/JsonApiDotNetCore/pull/1132) was built to make JSON:API idempotent, but it was never finished due to a lack of community interest. +> Please [open an issue](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/new?labels=enhancement) if idempotency matters to you. diff --git a/docs/usage/advanced/model-state.md b/docs/usage/advanced/model-state.md new file mode 100644 index 0000000000..0117cd72e3 --- /dev/null +++ b/docs/usage/advanced/model-state.md @@ -0,0 +1,14 @@ +# ASP.NET Model Validation + +The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState) shows how to use [ASP.NET Model Validation](https://learn.microsoft.com/aspnet/web-api/overview/formats-and-model-binding/model-validation-in-aspnet-web-api) attributes. + +> [!TIP] +> See [Atomic Operations](~/usage/advanced/operations.md) for how to implement a custom model validator. + +The resource types are decorated with Model Validation attributes, such as `[Required]`, `[RegularExpression]`, `[MinLength]`, and `[Range]`. + +Only the fields that appear in a request body (partial POST/PATCH) are validated. +When validation fails, the source pointer in the response indicates which attribute(s) are invalid. + +Model Validation is enabled by default, but can be [turned off in options](~/usage/options.md#modelstate-validation). +Aside from adding validation attributes to your resource properties, no further code is needed. diff --git a/docs/usage/advanced/multi-tenancy.md b/docs/usage/advanced/multi-tenancy.md new file mode 100644 index 0000000000..d6e5b73f62 --- /dev/null +++ b/docs/usage/advanced/multi-tenancy.md @@ -0,0 +1,21 @@ +# Multi-tenancy + +The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy) shows how to handle multiple tenants in a single database. + +> [!TIP] +> To use a different database per tenant, see [this](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/src/Examples/DatabasePerTenantExample) example instead. +> Its `DbContext` dynamically sets the connection string per request. This requires the database structure to be identical for all tenants. + +The essence of implementing multi-tenancy within a single database is instructing Entity Framework Core to add implicit filters when entities are queried. +See the usage of `HasQueryFilter` in the `DbContext` class. It injects an `ITenantProvider` to determine the active tenant for the current HTTP request. + +> [!NOTE] +> For simplicity, this example uses a route parameter to indicate the active tenant. +> Provide your own `ITenantProvider` to determine the tenant from somewhere else, such as the incoming OAuth token. + +The generic `MultiTenantResourceService` transparently sets the tenant ID when creating a new resource. +Furthermore, it performs extra queries to ensure relationship changes apply to the current tenant, and to produce better error messages. + +While `MultiTenantResourceService` is used for both resource types, _only_ the `WebShop` resource type implements `IHasTenant`. +The related resource type `WebProduct` does not. Because the products table has a foreign key to the (tenant-specific) shop it belongs to, it doesn't need a `TenantId` column. +When a JSON:API request for web products executes, the `HasQueryFilter` in the `DbContext` ensures that only products belonging to the tenant-specific shop are returned. diff --git a/docs/usage/advanced/operations.md b/docs/usage/advanced/operations.md new file mode 100644 index 0000000000..aec2b9fe4d --- /dev/null +++ b/docs/usage/advanced/operations.md @@ -0,0 +1,15 @@ +# Atomic Operations + +The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations) covers usage of the [Atomic Operations](https://jsonapi.org/ext/atomic/) extension, which enables sending multiple changes in a single request. + +- Operations for creating, updating, and deleting resources and relationships are shown. +- If one of the operations fails, the transaction is rolled back. +- Local IDs are used to reference resources created in a preceding operation within the same request. +- A custom controller restricts which operations are allowed, per resource type. +- The maximum number of operations per request can be configured at startup. +- For efficiency, operations are validated upfront (before accessing the database). If validation fails, the list of all errors is returned. + - Takes [ASP.NET Model Validation](https://learn.microsoft.com/aspnet/web-api/overview/formats-and-model-binding/model-validation-in-aspnet-web-api) attributes into account. + - See `DateMustBeInThePastAttribute` for how to implement a custom model validator. +- Various interactions with resource definitions are shown. + +The Atomic Operations extension is enabled after an operations controller is added to the project. No further code is needed. diff --git a/docs/usage/advanced/query-string-functions.md b/docs/usage/advanced/query-string-functions.md new file mode 100644 index 0000000000..214228d654 --- /dev/null +++ b/docs/usage/advanced/query-string-functions.md @@ -0,0 +1,23 @@ +# Query String Functions + +The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions) shows how to define custom functions that clients can use in JSON:API query string parameters. + +- IsUpperCase: Adds the `isUpperCase` function, which can be used in filters on `string` attributes. + - Returns whether the attribute value is uppercase. + - Example usage: `GET /blogs/1/posts?filter=and(isUpperCase(caption),not(isUpperCase(url)))` +- StringLength: Adds the `length` function, which can be used in filters and sorts on `string` attributes. + - Returns the number of characters in the attribute value. + - Example filter usage: `GET /blogs?filter=greaterThan(length(title),'2')` + - Example sort usage: `GET /blogs/1/posts?sort=length(caption),-length(url)` +- Sum: Adds the `sum` function, which can be used in filters. + - Returns the sum of the numeric attribute values in related resources. + - Example: `GET /blogPosts?filter=greaterThan(sum(comments,numStars),'4')` +- TimeOffset: Adds the `timeOffset` function, which can be used in filters on `DateTime` attributes. + - Calculates the difference between the attribute value and the current date. + - A generic resource definition intercepts all filters, rewriting the usage of `timeOffset` into the equivalent filters on the target attribute. + - Example: `GET /reminders?filter=greaterOrEqual(remindsAt,timeOffset('+0:10:00'))` + +The basic pattern to implement a custom function is to: +- Define a custom expression type, which inherits from one of the built-in expression types, such as `FilterExpression` or `FunctionExpression`. +- Inherit from one of the built-in parsers, such as `FilterParser` or `SortParser`, to convert tokens to your custom expression type. Override the `ParseFilter` or `ParseFunction` method. +- Inherit from one of the built-in query clause builders, such as `WhereClauseBuilder` or `OrderClauseBuilder`, to produce a LINQ expression for your custom expression type. Override the `DefaultVisit` method. diff --git a/docs/usage/advanced/resource-injection.md b/docs/usage/advanced/resource-injection.md new file mode 100644 index 0000000000..c4e82a40fd --- /dev/null +++ b/docs/usage/advanced/resource-injection.md @@ -0,0 +1,11 @@ +# Injecting services in resource types + +The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/ResourceConstructorInjection) shows how to inject services into resource types. + +Because Entity Framework Core doesn't support injecting arbitrary services into entity types (only a few special types), a workaround is used. +Instead of injecting the desired services directly, the `DbContext` is injected, which injects the desired services and exposes them via properties. + +- The `PostOffice` and `GiftCertificate` resource types both inject the `DbContext` in their constructors. +- The `DbContext` injects `TimeProvider` and exposes it through a property. +- `GiftCertificate` obtains the `TimeProvider` via the `DbContext` property to calculate the value for its exposed `HasExpired` property, which depends on the current time. +- `PostOffice` obtains the `TimeProvider` via the `DbContext` property to calculate the value for its exposed `IsOpen` property, which depends on the current time. diff --git a/docs/usage/advanced/soft-deletion.md b/docs/usage/advanced/soft-deletion.md new file mode 100644 index 0000000000..cebc18e91c --- /dev/null +++ b/docs/usage/advanced/soft-deletion.md @@ -0,0 +1,15 @@ +# Soft Deletion + +The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/SoftDeletion) demonstrates how to implement soft deletion of resources. + +> [!TIP] +> This scenario is comparable with [Archiving](~/usage/advanced/archiving.md). +> The difference is that soft-deleted resources are never accessible by JSON:API clients (despite still being stored in the database), whereas archived resources _are_ accessible. + +The essence of implementing soft deletion is instructing Entity Framework Core to add implicit filters when entities are queried. +See the usage of `HasQueryFilter` in the `DbContext` class. + +The `ISoftDeletable` interface provides the `SoftDeletedAt` database column. The `Company` and `Department` resource types implement this interface to indicate they use soft deletion. + +The generic `SoftDeletionAwareResourceService` overrides the `DeleteAsync` method to soft-delete a resource instead of truly deleting it, if it implements `ISoftDeletable`. +Furthermore, it performs extra queries to ensure relationship changes do not reference soft-deleted resources, and to produce better error messages. diff --git a/docs/usage/advanced/state-machine.md b/docs/usage/advanced/state-machine.md new file mode 100644 index 0000000000..371300995a --- /dev/null +++ b/docs/usage/advanced/state-machine.md @@ -0,0 +1,11 @@ +# State Transitions in Resource Updates + +The code [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody) shows how to validate state transitions when updating a resource. + +This feature is implemented using a custom resource definition: + +- The `Workflow` resource type contains a `Stage` property of type `WorkflowStage`. +- The `WorkflowStage` enumeration lists a workflow's possible states. +- `WorkflowDefinition` contains a hard-coded stage transition table defining the valid transitions. For example, a workflow in stage `InProgress` can be changed to `OnHold` or `Canceled`, but not `Created`. + - The `OnPrepareWriteAsync` method is overridden to capture the stage currently stored in the database in the `_previousStage` private field. + - The `OnWritingAsync` method is overridden to verify whether the stage change is permitted. It consults the stage transition table to determine whether there's a path from `_previousStage` to the to-be-stored stage, producing an error if there isn't. diff --git a/docs/usage/advanced/toc.yml b/docs/usage/advanced/toc.yml new file mode 100644 index 0000000000..9d45cd04b3 --- /dev/null +++ b/docs/usage/advanced/toc.yml @@ -0,0 +1,38 @@ +- name: Authorization Scopes + href: auth-scopes.md +- name: BLOBs + href: blobs.md +- name: Microservices + href: microservices.md +- name: Multi-tenancy + href: multi-tenancy.md +- name: Atomic Operations + href: operations.md +- name: Query String Functions + href: query-string-functions.md +- name: Alternate Routes + href: alternate-routes.md +- name: Content Negotiation + href: content-negotiation.md +- name: Error Handling + href: error-handling.md +- name: Hosting in IIS + href: hosting-iis.md +- name: ID Obfuscation + href: id-obfuscation.md +- name: Soft Deletion + href: soft-deletion.md +- name: Archiving + href: archiving.md +- name: ASP.NET Model Validation + href: model-state.md +- name: State Transitions in Resource Updates + href: state-machine.md +- name: Links + href: links.md +- name: Composite Keys + href: composite-keys.md +- name: Eager Loading + href: eager-loading.md +- name: Injecting services in resource types + href: resource-injection.md diff --git a/docs/usage/caching.md b/docs/usage/caching.md index d5f644997b..4243fd8be2 100644 --- a/docs/usage/caching.md +++ b/docs/usage/caching.md @@ -2,10 +2,10 @@ _since v4.2_ -GET requests return an [ETag](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag) HTTP header, which can be used by the client in subsequent requests to save network bandwidth. +GET requests return an [ETag](https://developer.mozilla.org/docs/Web/HTTP/Headers/ETag) HTTP header, which can be used by the client in subsequent requests to save network bandwidth. -Be aware that the returned ETag represents the entire response body (a 'resource' in HTTP terminology) for a request URL that includes the query string. -This is unrelated to JSON:API resources. Therefore, we do not use ETags for optimistic concurrency. +Be aware that the returned ETag represents the entire response body (a "resource" in HTTP terminology) for the full request URL, including the query string. +A resource in HTTP is unrelated to a JSON:API resource. Therefore, we do not use ETags for optimistic concurrency. Getting a list of resources returns an ETag: @@ -26,7 +26,7 @@ ETag: "7FFF010786E2CE8FC901896E83870E00" } ``` -The request is later resent using the received ETag. The server data has not changed at this point. +The request is later resent using the same ETag received earlier. The server data has not changed at this point. ```http GET /articles?sort=-lastModifiedAt HTTP/1.1 @@ -59,8 +59,8 @@ ETag: "356075D903B8FE8D9921201A7E7CD3F9" "data": [ ... ] } ``` - -**Note:** To just poll for changes (without fetching them), send a HEAD request instead: +> [!TIP] +> To just poll for changes (without fetching them), send a HEAD request instead. ```http HEAD /articles?sort=-lastModifiedAt HTTP/1.1 diff --git a/docs/usage/common-pitfalls.md b/docs/usage/common-pitfalls.md new file mode 100644 index 0000000000..60162cfd37 --- /dev/null +++ b/docs/usage/common-pitfalls.md @@ -0,0 +1,150 @@ +# Common Pitfalls + +This section lists various problems we've seen users run into over the years when using JsonApiDotNetCore. +See also [Frequently Asked Questions](~/getting-started/faq.md). + +#### JSON:API resources are not DTOs or ViewModels +This is a common misconception. +Similar to a database model, which consists of tables and foreign keys, JSON:API defines resources that are connected via relationships. +You're opening up a can of worms when trying to model a single table to multiple JSON:API resources. + +This is best clarified using an example. Let's assume we're building a public website and an admin portal, both using the same API. +The API uses the database tables "Customers" and "LoginAccounts", having a one-to-one relationship between them. + +Now let's try to define the resource classes: +```c# +[Table("Customers")] +public sealed class WebCustomer : Identifiable +{ + [Attr] + public string Name { get; set; } = null!; + + [HasOne] + public LoginAccount? Account { get; set; } +} + +[Table("Customers")] +public sealed class AdminCustomer : Identifiable +{ + [Attr] + public string Name { get; set; } = null!; + + [Attr] + public string? CreditRating { get; set; } + + [HasOne] + public LoginAccount? Account { get; set; } +} + +[Table("LoginAccounts")] +public sealed class LoginAccount : Identifiable +{ + [Attr] + public string EmailAddress { get; set; } = null!; + + [HasOne] + public ??? Customer { get; set; } +} +``` +Did you notice the missing type of the `LoginAccount.Customer` property? We must choose between `WebCustomer` or `AdminCustomer`, but neither is correct. +This is only one of the issues you'll run into. Just don't go there. + +The right way to model this is by having only `Customer` instead of `WebCustomer` and `AdminCustomer`. And then: +- Hide the `CreditRating` property for web users using [this](~/usage/extensibility/resource-definitions.md#excluding-fields) approach. +- Block web users from setting the `CreditRating` property from POST/PATCH resource endpoints by either: + - Detecting if the `CreditRating` property has changed, such as done [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDefinition.cs). + - Injecting `ITargetedFields`, throwing an error when it contains the `CreditRating` property. + +#### JSON:API resources are not DDD domain entities +In [Domain-driven design](https://martinfowler.com/bliki/DomainDrivenDesign.html), it's considered best practice to implement business rules inside entities, with changes being controlled through an aggregate root. +This paradigm [doesn't work well](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1092#issuecomment-932749676) with JSON:API, because each resource can be changed in isolation. +So if your API needs to guard invariants such as "the sum of all orders must never exceed 500 dollars", then you're better off with an RPC-style API instead of the REST paradigm that JSON:API follows. + +Adding constructors to resource classes that validate incoming parameters before assigning them to properties does not work. +Entity Framework Core [supports](https://learn.microsoft.com/ef/core/modeling/constructors#binding-to-mapped-properties) that, +but does so via internal implementation details that are inaccessible by JsonApiDotNetCore. + +In JsonApiDotNetCore, resources are what DDD calls [anemic models](https://thedomaindrivendesign.io/anemic-model/). +Validation and business rules are typically implemented in [Resource Definitions](~/usage/extensibility/resource-definitions.md). + +#### Model relationships instead of foreign key attributes +It may be tempting to expose numeric resource attributes such as `customerId`, `orderId`, etc. You're better off using relationships instead, because they give you +the richness of JSON:API. For example, it enables users to include related resources in a single request, apply filters over related resources and use dedicated endpoints for updating relationships. +As an API developer, you'll benefit from rich input validation and fine-grained control for setting what's permitted when users access relationships. + +#### Model relationships instead of complex (JSON) attributes +Similar to the above, returning a complex object takes away all the relationship features of JSON:API. Users can't filter inside a complex object. Or update +a nested value, without risking accidentally overwriting another unrelated nested value from a concurrent request. Basically, there's no partial PATCH to prevent that. + +#### Stay away from stored procedures +There are [many reasons](https://stackoverflow.com/questions/1761601/is-the-usage-of-stored-procedures-a-bad-practice/9483781#9483781) to not use stored procedures. +But with JSON:API, there's an additional concern. Due to its dynamic nature of filtering, sorting, pagination, sparse fieldsets, and including related resources, +the number of required stored procedures to support all that either explodes, or you'll end up with one extremely complex stored proceduce to handle it all. +With stored procedures, you're either going to have a lot of work to do, or you'll end up with an API that has very limited capabilities. +Neither sounds very compelling. If stored procedures is what you need, you're better off creating an RPC-style API that doesn't use JsonApiDotNetCore. + +#### Do not use `[ApiController]` on JSON:API controllers +Although recommended by Microsoft for hard-written controllers, the opinionated behavior of [`[ApiController]`](https://learn.microsoft.com/aspnet/core/web-api/#apicontroller-attribute) violates the JSON:API specification. +Despite JsonApiDotNetCore trying its best to deal with it, the experience won't be as good as leaving it out. + +#### Don't use auto-generated controllers with shared models + +When model classes are defined in a separate project, the controllers are generated in that project as well, which is probably not what you want. +For details, see [here](~/usage/extensibility/controllers.md#auto-generated-controllers). + +#### Register/override injectable services +Register your JSON:API resource services, resource definitions and repositories with `services.AddResourceService/AddResourceDefinition/AddResourceRepository()` instead of `services.AddScoped()`. +When using [Auto-discovery](~/usage/resource-graph.md#auto-discovery), you don't need to register these at all. + +> [!NOTE] +> In older versions of JsonApiDotNetCore, registering your own services in the IoC container *afterwards* increased the chances that your replacements would take effect. + +#### Never use the Entity Framework Core In-Memory Database Provider +When using this provider, many invalid mappings go unnoticed, leading to strange errors or wrong behavior. A real SQL engine fails to create the schema when mappings are invalid. +If you're in need of a quick setup, use [SQLite](https://www.sqlite.org/). After adding its [NuGet package](https://www.nuget.org/packages/Microsoft.EntityFrameworkCore.Sqlite), it's as simple as: +```c# +// Program.cs +builder.Services.AddSqlite("Data Source=temp.db"); +``` +Which creates `temp.db` on disk. Simply deleting the file gives you a clean slate. +This is a lot more convenient compared to using [SqlLocalDB](https://learn.microsoft.com/sql/database-engine/configure-windows/sql-server-express-localdb), which runs a background service that breaks if you delete its underlying storage files. + +However, even SQLite does not support all queries produced by Entity Framework Core. You'll get the best (and fastest) experience with [PostgreSQL in a docker container](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/run-docker-postgres.ps1). + +#### One-to-one relationships require custom Entity Framework Core mappings +Entity Framework Core has great conventions and sane mapping defaults. But two of them are problematic for JSON:API: identifying foreign keys and default delete behavior. +See [here](~/usage/resources/relationships.md#one-to-one-relationships-in-entity-framework-core) for how to get it right. + +#### Prefer model attributes over fluent mappings +Validation attributes such as `[Required]` are detected by ASP.NET ModelState validation, Entity Framework Core, OpenAPI, and JsonApiDotNetCore. +When using a Fluent API instead, the other frameworks cannot know about it, resulting in a less streamlined experience. + +#### Validation of `[Required]` value types doesn't work +This is a limitation of ASP.NET ModelState validation. For example: +```c# +[Required] public int Age { get; set; } +``` +won't cause a validation error when sending `0` or omitting it entirely in the request body. +This limitation does not apply to reference types. +The workaround is to make it nullable: +```c# +[Required] public int? Age { get; set; } +``` +Entity Framework Core recognizes this and generates a non-nullable column. + +#### Don't change resource property values from POST/PATCH controller methods +It simply won't work. Without going into details, this has to do with JSON:API partial POST/PATCH. +Use [Resource Definition](~/usage/extensibility/resource-definitions.md) callback methods to apply such changes from code. + +#### You can't mix up pipeline methods +For example, you can't call `service.UpdateAsync()` from `controller.GetAsync()`, or call `service.SetRelationshipAsync()` from `controller.PatchAsync()`. +The reason is that various ambient injectable objects are in play, used to track what's going on during the request pipeline internally. +And they won't match up with the current endpoint when switching to a different pipeline halfway during a request. + +If you need such side effects, it's easiest to inject your `DbContext` in the controller, directly apply the changes on it and save. +A better way is to inject your `DbContext` in a [Resource Definition](~/usage/extensibility/resource-definitions.md) and apply the changes there. + +#### Concurrency tokens (timestamp/rowversion/xmin) won't work +While we'd love to support such [tokens for optimistic concurrency](https://learn.microsoft.com/ef/core/saving/concurrency), +it turns out that the implementation is far from trivial. We've come a long way, but aren't sure how it should work when relationship endpoints and atomic operations are involved. +If you're interested, we welcome your feedback at https://github.com/json-api-dotnet/JsonApiDotNetCore/pull/1119. diff --git a/docs/usage/extensibility/controllers.md b/docs/usage/extensibility/controllers.md index 0c71f45090..254b305ed9 100644 --- a/docs/usage/extensibility/controllers.md +++ b/docs/usage/extensibility/controllers.md @@ -2,9 +2,11 @@ To expose API endpoints, ASP.NET controllers need to be defined. +## Auto-generated controllers + _since v5_ -Controllers are auto-generated (using [source generators](https://docs.microsoft.com/en-us/dotnet/csharp/roslyn-sdk/source-generators-overview)) when you add `[Resource]` on your model class: +Controllers are auto-generated (using [source generators](https://learn.microsoft.com/dotnet/csharp/roslyn-sdk/#source-generators)) when you add `[Resource]` on your model class: ```c# [Resource] // Generates ArticlesController.g.cs @@ -14,7 +16,12 @@ public class Article : Identifiable } ``` -## Resource Access Control +> [!NOTE] +> Auto-generated controllers are convenient to get started, but may not work as expected with certain customizations. +> For example, when model classes are defined in a separate project, the controllers are generated in that project as well, which is probably not what you want. +> In such cases, it's perfectly fine to use [explicit controllers](#explicit-controllers) instead. + +### Resource Access Control It is often desirable to limit which endpoints are exposed on your controller. A subset can be specified too: @@ -39,7 +46,7 @@ DELETE http://localhost:14140/articles/1 HTTP/1.1 ```json { "links": { - "self": "/articles" + "self": "/articles/1" }, "errors": [ { @@ -52,7 +59,7 @@ DELETE http://localhost:14140/articles/1 HTTP/1.1 } ``` -## Augmenting controllers +### Augmenting controllers Auto-generated controllers can easily be augmented because they are partial classes. For example: @@ -91,9 +98,9 @@ partial class ArticlesController In case you don't want to use auto-generated controllers and define them yourself (see below), remove `[Resource]` from your models or use `[Resource(GenerateControllerEndpoints = JsonApiEndpoints.None)]`. -## Earlier versions +## Explicit controllers -In earlier versions of JsonApiDotNetCore, you needed to create controllers that inherit from `JsonApiController`. For example: +To define your own controller class, inherit from `JsonApiController`. For example: ```c# public class ArticlesController : JsonApiController diff --git a/docs/usage/extensibility/layer-overview.md b/docs/usage/extensibility/layer-overview.md index 2fe99e2fbd..9233508179 100644 --- a/docs/usage/extensibility/layer-overview.md +++ b/docs/usage/extensibility/layer-overview.md @@ -23,8 +23,6 @@ on your needs, you may want to replace other parts by deriving from the built-in ## Replacing injected services -**Note:** If you're using [auto-discovery](~/usage/resource-graph.md#auto-discovery), then resource services, repositories and resource definitions will be automatically registered for you. - Replacing built-in services is done on a per-resource basis and can be done at startup. For convenience, extension methods are provided to register layers on all their implemented interfaces. @@ -37,3 +35,6 @@ builder.Services.AddResourceDefinition(); builder.Services.AddScoped(); builder.Services.AddScoped(); ``` + +> [!TIP] +> If you're using [auto-discovery](~/usage/resource-graph.md#auto-discovery), then resource services, repositories and resource definitions will be automatically registered for you. diff --git a/docs/usage/extensibility/middleware.md b/docs/usage/extensibility/middleware.md index 62528893d3..dbbe81699f 100644 --- a/docs/usage/extensibility/middleware.md +++ b/docs/usage/extensibility/middleware.md @@ -3,9 +3,9 @@ The default middleware validates incoming `Content-Type` and `Accept` HTTP headers. Based on routing configuration, it fills `IJsonApiRequest`, an injectable object that contains JSON:API-related information about the request being processed. -It is possible to replace the built-in middleware components by configuring the IoC container and by configuring `MvcOptions`. +It is possible to replace the built-in middleware components by configuring the IoC container and by configuring `MvcOptions`. -## Configuring the IoC container +## Configuring the IoC container The following example replaces the internal exception filter with a custom implementation. diff --git a/docs/usage/extensibility/repositories.md b/docs/usage/extensibility/repositories.md index 102406e71b..733634bf33 100644 --- a/docs/usage/extensibility/repositories.md +++ b/docs/usage/extensibility/repositories.md @@ -13,13 +13,14 @@ builder.Services.AddScoped, ArticleReposi In v4.0 we introduced an extension method that you can use to register a resource repository on all of its JsonApiDotNetCore interfaces. This is helpful when you implement (a subset of) the resource interfaces and want to register them all in one go. -**Note:** If you're using [auto-discovery](~/usage/resource-graph.md#auto-discovery), this happens automatically. - ```c# // Program.cs builder.Services.AddResourceRepository(); ``` +> [!TIP] +> If you're using [auto-discovery](~/usage/resource-graph.md#auto-discovery), then resource services, repositories and resource definitions will be automatically registered for you. + A sample implementation that performs authorization might look like this. All of the methods in EntityFrameworkCoreRepository will use the `GetAll()` method to get the `DbSet`, so this is a good method to apply filters such as user or tenant authorization. diff --git a/docs/usage/extensibility/resource-definitions.md b/docs/usage/extensibility/resource-definitions.md index af4f8a27c5..644d43fb75 100644 --- a/docs/usage/extensibility/resource-definitions.md +++ b/docs/usage/extensibility/resource-definitions.md @@ -7,19 +7,19 @@ They are resolved from the dependency injection container, so you can inject dep In v4.2 we introduced an extension method that you can use to register your resource definition. -**Note:** If you're using [auto-discovery](~/usage/resource-graph.md#auto-discovery), this happens automatically. - ```c# // Program.cs builder.Services.AddResourceDefinition(); ``` -**Note:** Prior to the introduction of auto-discovery (in v3), you needed to register the -resource definition on the container yourself: +> [!TIP] +> If you're using [auto-discovery](~/usage/resource-graph.md#auto-discovery), then resource services, repositories and resource definitions will be automatically registered for you. -```c# -builder.Services.AddScoped, ArticleDefinition>(); -``` +> [!NOTE] +> Prior to the introduction of auto-discovery (in v3), you needed to register the resource definition on the container yourself: +> ```c# +> builder.Services.AddScoped, ArticleDefinition>(); +> ``` ## Customizing queries @@ -29,15 +29,16 @@ For various reasons (see examples below) you may need to change parts of the que `JsonApiResourceDefinition` (which is an empty implementation of `IResourceDefinition`) provides overridable methods that pass you the result of query string parameter parsing. The value returned by you determines what will be used to execute the query. -An intermediate format (`QueryExpression` and derived types) is used, which enables us to separate JSON:API implementation +An intermediate format (`QueryExpression` and derived types) is used, which enables us to separate JSON:API implementation from Entity Framework Core `IQueryable` execution. ### Excluding fields -There are some cases where you want attributes (or relationships) conditionally excluded from your resource response. +There are some cases where you want attributes or relationships conditionally excluded from your resource response. For example, you may accept some sensitive data that should only be exposed to administrators after creation. -**Note:** to exclude attributes unconditionally, use `[Attr(Capabilities = ~AttrCapabilities.AllowView)]` on a resource class property. +> [!NOTE] +> To exclude fields unconditionally, [attribute capabilities](~/usage/resources/attributes.md#capabilities) and [relationship capabilities](~/usage/resources/relationships.md#capabilities) can be used instead. ```c# public class UserDefinition : JsonApiResourceDefinition @@ -218,7 +219,8 @@ _since v3_ You can define additional query string parameters with the LINQ expression that should be used. If the key is present in a query string, the supplied LINQ expression will be added to the database query. -Note this directly influences the Entity Framework Core `IQueryable`. As opposed to using `OnApplyFilter`, this enables the full range of Entity Framework Core operators. +> [!NOTE] +> This directly influences the Entity Framework Core `IQueryable`. As opposed to using `OnApplyFilter`, this enables the full range of Entity Framework Core operators. But it only works on primary resource endpoints (for example: /articles, but not on /blogs/1/articles or /blogs?include=articles). ```c# diff --git a/docs/usage/extensibility/services.md b/docs/usage/extensibility/services.md index bc3dd5bff8..90dea1352b 100644 --- a/docs/usage/extensibility/services.md +++ b/docs/usage/extensibility/services.md @@ -81,42 +81,34 @@ In some cases it may be necessary to only expose a few actions on a resource. Fo This interface hierarchy is defined by this tree structure. -``` -IResourceService -| -+-- IResourceQueryService -| | -| +-- IGetAllService -| | GET / -| | -| +-- IGetByIdService -| | GET /{id} -| | -| +-- IGetSecondaryService -| | GET /{id}/{relationship} -| | -| +-- IGetRelationshipService -| GET /{id}/relationships/{relationship} -| -+-- IResourceCommandService - | - +-- ICreateService - | POST / - | - +-- IUpdateService - | PATCH /{id} - | - +-- IDeleteService - | DELETE /{id} - | - +-- IAddToRelationshipService - | POST /{id}/relationships/{relationship} - | - +-- ISetRelationshipService - | PATCH /{id}/relationships/{relationship} - | - +-- IRemoveFromRelationshipService - DELETE /{id}/relationships/{relationship} +```mermaid +classDiagram +direction LR +class IResourceService +class IResourceQueryService +class IGetAllService ["IGetAllService\nGET /"] +class IGetByIdService ["IGetByIdService\nGET /{id}"] +class IGetSecondaryService ["IGetSecondaryService\nGET /{id}/{relationship}"] +class IGetRelationshipService ["IGetRelationshipService\nGET /{id}/relationships/{relationship}"] +class IResourceCommandService +class ICreateService ["ICreateService\nPOST /"] +class IUpdateService ["IUpdateService\nPATCH /{id}"] +class IDeleteService ["IDeleteService\nDELETE /{id}"] +class IAddToRelationshipService ["IAddToRelationshipService\nPOST /{id}/relationships/{relationship}"] +class ISetRelationshipService ["ISetRelationshipService\nPATCH /{id}/relationships/{relationship}"] +class IRemoveFromRelationshipService ["IRemoveFromRelationshipService\nDELETE /{id}/relationships/{relationship}"] +IResourceService <|-- IResourceQueryService +IResourceQueryService<|-- IGetAllService +IResourceQueryService<|-- IGetByIdService +IResourceQueryService<|-- IGetSecondaryService +IResourceQueryService<|-- IGetRelationshipService +IResourceService <|-- IResourceCommandService +IResourceCommandService <|-- ICreateService +IResourceCommandService <|-- IUpdateService +IResourceCommandService <|-- IDeleteService +IResourceCommandService <|-- IAddToRelationshipService +IResourceCommandService <|-- ISetRelationshipService +IResourceCommandService <|-- IRemoveFromRelationshipService ``` In order to take advantage of these interfaces you first need to register the service for each implemented interface. @@ -135,13 +127,14 @@ builder.Services.AddScoped, ArticleService>(); In v3.0 we introduced an extension method that you can use to register a resource service on all of its JsonApiDotNetCore interfaces. This is helpful when you implement (a subset of) the resource interfaces and want to register them all in one go. -**Note:** If you're using [auto-discovery](~/usage/resource-graph.md#auto-discovery), this happens automatically. - ```c# // Program.cs builder.Services.AddResourceService(); ``` +> [!TIP] +> If you're using [auto-discovery](~/usage/resource-graph.md#auto-discovery), then resource services, repositories and resource definitions will be automatically registered for you. + Then on your model, pass in the set of endpoints to expose (the ones that you've registered services for): ```c# diff --git a/docs/usage/extensibility/toc.yml b/docs/usage/extensibility/toc.yml new file mode 100644 index 0000000000..4a32581a60 --- /dev/null +++ b/docs/usage/extensibility/toc.yml @@ -0,0 +1,14 @@ +- name: Layer Overview + href: layer-overview.md +- name: Resource Definitions + href: resource-definitions.md +- name: Controllers + href: controllers.md +- name: Resource Services + href: services.md +- name: Resource Repositories + href: repositories.md +- name: Middleware + href: middleware.md +- name: Query Strings + href: query-strings.md diff --git a/docs/usage/faq.md b/docs/usage/faq.md new file mode 100644 index 0000000000..cbb32c4c00 --- /dev/null +++ b/docs/usage/faq.md @@ -0,0 +1,176 @@ +# Frequently Asked Questions + +#### Where can I find documentation and examples? +The [documentation](~/usage/resources/index.md) covers basic features, as well as [advanced use cases](~/usage/advanced/index.md). Several runnable example projects are available [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/src/Examples). + +#### Why don't you use the built-in OpenAPI support in ASP.NET Core? +The structure of JSON:API request and response bodies differs significantly from the signature of JsonApiDotNetCore controllers. +JsonApiDotNetCore provides OpenAPI support using [Swashbuckle](https://github.com/domaindrivendev/Swashbuckle.AspNetCore), a mature and feature-rich library that is highly extensible. +The [OpenAPI support in ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/overview) is still very young +and doesn't provide the level of extensibility needed for JsonApiDotNetCore. + +#### What's available to implement a JSON:API client? +To generate a typed client (specific to the resource types in your project), consider using our [OpenAPI](https://www.jsonapi.net/usage/openapi.html) NuGet package. + +If you need a generic client, it depends on the programming language used. There's an overwhelming list of client libraries at https://jsonapi.org/implementations/#client-libraries. + +The JSON object model inside JsonApiDotNetCore is tweaked for server-side handling (be tolerant at inputs and strict at outputs). +While you technically *could* use our `JsonSerializer` converters from a .NET client application with some hacks, we don't recommend doing so. +You'll need to build the resource graph on the client and rely on internal implementation details that are subject to change in future versions. + +#### How can I debug my API project? +Due to auto-generated controllers, you may find it hard to determine where to put your breakpoints. +In Visual Studio, controllers are accessible below **Solution Explorer > Project > Dependencies > Analyzers > JsonApiDotNetCore.SourceGenerators**. + +After turning on [Source Link](https://devblogs.microsoft.com/dotnet/improving-debug-time-productivity-with-source-link/#enabling-source-link) (which enables to download the JsonApiDotNetCore source code from GitHub), you can step into our source code and add breakpoints there too. + +Here are some key places in the execution pipeline to set a breakpoint: +- `JsonApiRoutingConvention.Apply`: Controllers are registered here (executes once at startup) +- `JsonApiMiddleware.InvokeAsync`: Content negotiation and `IJsonApiRequest` setup +- `QueryStringReader.ReadAll`: Parses the query string parameters +- `JsonApiReader.ReadAsync`: Parses the request body +- `OperationsProcessor.ProcessAsync`: Entry point for handling atomic operations +- `JsonApiResourceService`: Called by controllers, delegating to the repository layer +- `EntityFrameworkCoreRepository.ApplyQueryLayer`: Builds the `IQueryable<>` that is offered to Entity Framework Core (which turns it into SQL) +- `JsonApiWriter.WriteAsync`: Renders the response body +- `ExceptionHandler.HandleException`: Interception point for thrown exceptions + +Aside from debugging, you can get more info by: +- Including exception stack traces and incoming request bodies in error responses, as well as writing human-readable JSON: + + ```c# + // Program.cs + builder.Services.AddJsonApi(options => + { + options.IncludeExceptionStackTraceInErrors = true; + options.IncludeRequestBodyInErrors = true; + options.SerializerOptions.WriteIndented = true; + }); + ``` +- Turning on trace logging, or/and logging of executed SQL statements, by adding the following to your `appsettings.Development.json`: + + ```json + { + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Information", + "JsonApiDotNetCore": "Trace" + } + } + } + ``` +- Activate debug logging of LINQ expressions by adding a NuGet reference to [AgileObjects.ReadableExpressions](https://www.nuget.org/packages/AgileObjects.ReadableExpressions) in your project. + +#### What if my JSON:API resources do not exactly match the shape of my database tables? +We often find users trying to write custom code to solve that. They usually get it wrong or incomplete, and it may not perform well. +Or it simply fails because it cannot be translated to SQL. +The good news is that there's an easier solution most of the time: configure Entity Framework Core mappings to do the work. + +For example, if your primary key column is named "CustomerId" instead of "Id": +```c# +builder.Entity().Property(x => x.Id).HasColumnName("CustomerId"); +``` + +It certainly pays off to read up on these capabilities at [Creating and Configuring a Model](https://learn.microsoft.com/ef/core/modeling/). +Another great resource is [Learn Entity Framework Core](https://www.learnentityframeworkcore.com/configuration). + +#### Can I share my resource models with .NET Framework projects? +Yes, you can. Put your model classes in a separate project that only references [JsonApiDotNetCore.Annotations](https://www.nuget.org/packages/JsonApiDotNetCore.Annotations/). +This package contains just the JSON:API attributes and targets NetStandard 1.0, which makes it flexible to consume. +At startup, use [Auto-discovery](~/usage/resource-graph.md#auto-discovery) and point it to your shared project. + +#### What's the best place to put my custom business/validation logic? +For basic input validation, use the attributes from [ASP.NET ModelState Validation](https://learn.microsoft.com/aspnet/core/mvc/models/validation?source=recommendations&view=aspnetcore-7.0#built-in-attributes) to get the best experience. +JsonApiDotNetCore is aware of them and adjusts behavior accordingly. And it produces the best possible error responses. + +For non-trivial business rules that require custom code, the place to be is [Resource Definitions](~/usage/extensibility/resource-definitions.md). +They provide a callback-based model where you can respond to everything going on. +The great thing is that your callbacks are invoked for various endpoints. +For example, the filter callback on Author executes at `GET /authors?filter=`, `GET /books/1/authors?filter=` and `GET /books?include=authors?filter[authors]=`. +Likewise, the callbacks for changing relationships execute for POST/PATCH resource endpoints, as well as POST/PATCH/DELETE relationship endpoints. + +#### Can API users send multiple changes in a single request? +Yes, just activate [atomic operations](~/usage/writing/bulk-batch-operations.md). +It enables sending multiple changes in a batch request, which are executed in a database transaction. +If something fails, all changes are rolled back. The error response indicates which operation failed. + +#### Is there any way to add `[Authorize(Roles = "...")]` to the generated controllers? +Sure, this is possible. Simply add the attribute at the class level. +See the docs on [Augmenting controllers](~/usage/extensibility/controllers.md#augmenting-controllers). + +#### How do I expose non-JSON:API endpoints? +You can add your own controllers that do not derive from `(Base)JsonApiController` or `(Base)JsonApiOperationsController`. +Whatever you do in those is completely ignored by JsonApiDotNetCore. +This is useful if you want to add a few RPC-style endpoints or provide binary file uploads/downloads. + +A middle-ground approach is to add custom action methods to existing JSON:API controllers. +While you can route them as you like, they must return JSON:API resources. +And on error, a JSON:API error response is produced. +This is useful if you want to stay in the JSON:API-compliant world, but need to expose something non-standard, for example: `GET /users/me`. + +#### How do I optimize for high scalability and prevent denial of service? +Fortunately, JsonApiDotNetCore [scales pretty well](https://github.com/json-api-dotnet/PerformanceReports) under high load and/or large database tables. +It never executes filtering, sorting, or pagination in-memory and tries pretty hard to produce the most efficient query possible. +There are a few things to keep in mind, though: +- Prevent users from executing slow queries by locking down [attribute capabilities](~/usage/resources/attributes.md#capabilities) and [relationship capabilities](~/usage/resources/relationships.md#capabilities). + Ensure the right database indexes are in place for what you enable. +- Prevent users from fetching lots of data by tweaking [maximum page size/number](~/usage/options.md#pagination) and [maximum include depth](~/usage/options.md#maximum-include-depth). +- Avoid long-running transactions by tweaking `MaximumOperationsPerRequest` in options. +- Tell your users to utilize [E-Tags](~/usage/caching.md) to reduce network traffic. +- Not included in JsonApiDotNetCore: Apply general practices such as rate limiting, load balancing, authentication/authorization, blocking very large URLs/request bodies, etc. + +#### Can I offload requests to a background process? +Yes, that's possible. Override controller methods to return `HTTP 202 Accepted`, with a `Location` HTTP header where users can retrieve the result. +Your controller method needs to store the request state (URL, query string, and request body) in a queue, which your background process can read from. +From within your background process job handler, reconstruct the request state, execute the appropriate `JsonApiResourceService` method and store the result. +There's a basic example available at https://github.com/json-api-dotnet/JsonApiDotNetCore/pull/1144, which processes a captured query string. + +#### What if I want to use something other than Entity Framework Core? +This basically means you'll need to implement data access yourself. There are two approaches for interception: at the resource service level and at the repository level. +Either way, you can use the built-in query string and request body parsing, as well as routing, error handling, and rendering of responses. + +Here are some injectable request-scoped types to be aware of: +- `IJsonApiRequest`: This contains routing information, such as whether a primary, secondary, or relationship endpoint is being accessed. +- `ITargetedFields`: Lists the attributes and relationships from an incoming POST/PATCH resource request. Any fields missing there should not be stored (partial updates). +- `IEnumerable`: Provides access to the parsed query string parameters. +- `IEvaluatedIncludeCache`: This tells the response serializer which related resources to render. +- `ISparseFieldSetCache`: This tells the response serializer which fields to render in the `attributes` and `relationships` objects. + +You may also want to inject the singletons `IJsonApiOptions` (which contains settings such as default page size) and `IResourceGraph` (the JSON:API model of resources, attributes and relationships). + +So, back to the topic of where to intercept. It helps to familiarize yourself with the [execution pipeline](~/internals/queries.md). +Replacing at the service level is the simplest. But it means you'll need to read the parsed query string parameters and invoke +all resource definition callbacks yourself. And you won't get change detection (HTTP 203 Not Modified). +Take a look at [JsonApiResourceService](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs) to see what you're missing out on. + +You'll get a lot more out of the box if replacing at the repository level instead. You don't need to apply options or analyze query strings. +And most resource definition callbacks are handled. +That's because the built-in resource service translates all JSON:API query aspects of the request into a database-agnostic data structure called `QueryLayer`. +Now the hard part for you becomes reading that data structure and producing data access calls from that. +If your data store provides a LINQ provider, you can probably reuse [QueryableBuilder](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs), +which drives the translation into [System.Linq.Expressions](https://learn.microsoft.com/dotnet/csharp/programming-guide/concepts/expression-trees/). +Note however, that it also produces calls to `.Include("")`, which is an Entity Framework Core-specific extension method, so you'll need to +[prevent that from happening](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryLayerIncludeConverter.cs). + +The example [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs) compiles and executes +the LINQ query against an in-memory list of resources. +For [MongoDB](https://github.com/json-api-dotnet/JsonApiDotNetCore.MongoDb/blob/master/src/JsonApiDotNetCore.MongoDb/Repositories/MongoRepository.cs), we use the MongoDB LINQ provider. +If there's no LINQ provider available, the example [here](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/master/src/Examples/DapperExample/Repositories/DapperRepository.cs) may be of help, +which produces SQL and uses [Dapper](https://github.com/DapperLib/Dapper) for data access. + +> [!TIP] +> [ReadableExpressions](https://github.com/agileobjects/ReadableExpressions) is very helpful in trying to debug LINQ expression trees! + +#### 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! + +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. +So for any non-trivial changes, please open an issue first to discuss your approach and ensure it fits the product vision. + +#### Is there anything else I should be aware of? +See [Common Pitfalls](~/usage/common-pitfalls.md). diff --git a/docs/usage/meta.md b/docs/usage/meta.md index a115e25740..674d39413b 100644 --- a/docs/usage/meta.md +++ b/docs/usage/meta.md @@ -60,7 +60,7 @@ public class PersonDefinition : JsonApiResourceDefinition { return new Dictionary { - ["notice"] = "Check our intranet at http://www.example.com/employees/" + + ["notice"] = "Check our intranet at https://www.example.com/employees/" + $"{person.StringId} for personal details." }; } @@ -80,7 +80,7 @@ public class PersonDefinition : JsonApiResourceDefinition ... }, "meta": { - "notice": "Check our intranet at http://www.example.com/employees/1 for personal details." + "notice": "Check our intranet at https://www.example.com/employees/1 for personal details." } } ] diff --git a/docs/usage/openapi-client.md b/docs/usage/openapi-client.md new file mode 100644 index 0000000000..5dc40ce6fc --- /dev/null +++ b/docs/usage/openapi-client.md @@ -0,0 +1,355 @@ +> [!WARNING] +> OpenAPI support for JSON:API is currently experimental. The API and the structure of the OpenAPI document may change in future versions. + +# OpenAPI clients + +After [enabling OpenAPI](~/usage/openapi.md), you can generate a typed JSON:API client for your API in various programming languages. + +> [!NOTE] +> If you prefer a generic JSON:API client instead of a typed one, choose from the existing +> [client libraries](https://jsonapi.org/implementations/#client-libraries). + +The following code generators are supported, though you may try others as well: +- [NSwag](https://github.com/RicoSuter/NSwag) (v14.1 or higher): Produces clients for C# (requires `Newtonsoft.Json`) and TypeScript +- [Kiota](https://learn.microsoft.com/openapi/kiota/overview): Produces clients for C#, Go, Java, PHP, Python, Ruby, Swift and TypeScript + +# [NSwag](#tab/nswag) + +For C# clients, we provide an additional package that provides workarounds for bugs in NSwag and enables using partial POST/PATCH requests. + +To add it to your project, run the following command: +``` +dotnet add package JsonApiDotNetCore.OpenApi.Client.NSwag --prerelease +``` + +# [Kiota](#tab/kiota) + +For C# clients, we provide an additional package that provides workarounds for bugs in Kiota, as well as MSBuild integration. + +To add it to your project, run the following command: +``` +dotnet add package JsonApiDotNetCore.OpenApi.Client.Kiota --prerelease +``` + +--- + +## Getting started + +To generate your C# client, follow the steps below. + +# [NSwag](#tab/nswag) + +### Visual Studio + +The easiest way to get started is by using the built-in capabilities of Visual Studio. +The following steps describe how to generate and use a JSON:API client in C#, combined with our NuGet package. + +1. In **Solution Explorer**, right-click your client project, select **Add** > **Service Reference** and choose **OpenAPI**. + +1. On the next page, specify the OpenAPI URL to your JSON:API server, for example: `http://localhost:14140/swagger/v1/swagger.json`. + Specify `ExampleApiClient` as the class name, optionally provide a namespace and click **Finish**. + Visual Studio now downloads your swagger.json and updates your project file. + This adds a pre-build step that generates the client code. + + > [!TIP] + > To later re-download swagger.json and regenerate the client code, + > right-click **Dependencies** > **Manage Connected Services** and click the **Refresh** icon. + +1. Run package update now, which fixes incompatibilities and bugs from older versions. + +1. Add our client package to your project: + + ``` + dotnet add package JsonApiDotNetCore.OpenApi.Client.NSwag --prerelease + ``` + +1. Add code that calls one of your JSON:API endpoints. + + ```c# + using var httpClient = new HttpClient(); + var apiClient = new ExampleApiClient(httpClient); + + var getResponse = await apiClient.GetPersonCollectionAsync(new Dictionary + { + ["filter"] = "has(assignedTodoItems)", + ["sort"] = "-lastName", + ["page[size]"] = "5" + }); + + foreach (var person in getResponse.Data) + { + Console.WriteLine($"Found person {person.Id}: {person.Attributes!.DisplayName}"); + } + ``` + +1. Extend the demo code to send a partial PATCH request with the help of our package: + + ```c# + var updatePersonRequest = new UpdatePersonRequestDocument + { + Data = new DataInUpdatePersonRequest + { + Id = "1", + // Using TrackChangesFor to send "firstName: null" instead of omitting it. + Attributes = new TrackChangesFor(_apiClient) + { + Initializer = + { + FirstName = null, + LastName = "Doe" + } + }.Initializer + } + }; + + await ApiResponse.TranslateAsync(async () => + await _apiClient.PatchPersonAsync(updatePersonRequest.Data.Id, updatePersonRequest)); + + // The sent request looks like this: + // { + // "data": { + // "type": "people", + // "id": "1", + // "attributes": { + // "firstName": null, + // "lastName": "Doe" + // } + // } + // } + ``` + +> [!TIP] +> The [example project](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/src/Examples/OpenApiNSwagClientExample) contains an enhanced version +> that uses `IHttpClientFactory` for [scalability](https://learn.microsoft.com/dotnet/core/extensions/httpclient-factory) and +> [resiliency](https://learn.microsoft.com/aspnet/core/fundamentals/http-requests#use-polly-based-handlers) and logs the HTTP requests and responses. +> Additionally, the example shows how to write the swagger.json file to disk when building the server, which is imported from the client project. +> This keeps the server and client automatically in sync, which is handy when both are in the same solution. + +### Other IDEs + +The following section shows what to add to your client project file directly: + +```xml + + + + + + + + + http://localhost:14140/swagger/v1/swagger.json + ExampleApiClient + %(ClassName).cs + + +``` + +From here, continue from step 3 in the list of steps for Visual Studio. + +# [Kiota](#tab/kiota) + +To generate your C# client, first add the Kiota tool to your solution: + +``` +dotnet tool install microsoft.openapi.kiota +``` + +After adding the `JsonApiDotNetCore.OpenApi.Client.Kiota` package to your project, add a `KiotaReference` element +to your project file to import your OpenAPI file. For example: + +```xml + + + + $(MSBuildProjectName).GeneratedCode + ExampleApiClient + ./GeneratedCode + $(JsonApiExtraArguments) + + + +``` + +> [!NOTE] +> The `ExtraArguments` parameter is required for compatibility with JSON:API. + +Next, build your project. It runs the kiota command-line tool, which generates files in the `GeneratedCode` subdirectory. + +> [!CAUTION] +> If you're not using ``, at least make sure you're passing the `--backing-store` switch to the command-line tool, +> which is needed for JSON:API partial POST/PATCH requests to work correctly. + +Kiota is pretty young and therefore still rough around the edges. At the time of writing, there are various bugs, for which we have workarounds +in place. For a full example, see the [example project](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/src/Examples/OpenApiKiotaClientExample). + +--- + +## Configuration + +Various switches enable you to tweak the client generation to your needs. See the section below for an overview. + +# [NSwag](#tab/nswag) + +The `OpenApiReference` element can be customized using various [NSwag-specific MSBuild properties](https://github.com/RicoSuter/NSwag/blob/7d6df3af95081f3f0ed6dee04be8d27faa86f91a/src/NSwag.ApiDescription.Client/NSwag.ApiDescription.Client.props). +See [the source code](https://github.com/RicoSuter/NSwag/blob/master/src/NSwag.Commands/Commands/CodeGeneration/OpenApiToCSharpClientCommand.cs) for their meaning. +The `JsonApiDotNetCore.OpenApi.Client.NSwag` package sets various of these for optimal JSON:API support. + +> [!NOTE] +> Earlier versions of NSwag required the use of `` to specify command-line switches directly. +> This is no longer recommended and may conflict with the new MSBuild properties. + +For example, the following section puts the generated code in a namespace, makes the client class internal and generates an interface (handy when writing tests): + +```xml + + ExampleProject.GeneratedCode + internal + true + +``` + +# [Kiota](#tab/kiota) + +The available command-line switches for Kiota are described [here](https://learn.microsoft.com/openapi/kiota/using#client-generation). + +At the time of writing, Kiota provides [no official integration](https://github.com/microsoft/kiota/issues/3005) with MSBuild. +Our [example project](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/src/Examples/OpenApiKiotaClientExample) takes a stab at it, +which seems to work. If you're an MSBuild expert, please help out! + +```xml + + + + + + + + + + + + + + + +``` + +--- + +## Headers and caching + +The use of HTTP headers varies per client generator. To use [ETags for caching](~/usage/caching.md), see the notes below. + +# [NSwag](#tab/nswag) + +To gain access to HTTP response headers, add the following in a `PropertyGroup` or directly in the `OpenApiReference`: + +``` +true +``` + +This enables the following code, which is explained below: + +```c# +var getResponse = await ApiResponse.TranslateAsync(() => apiClient.GetPersonCollectionAsync()); +string eTag = getResponse.Headers["ETag"].Single(); +Console.WriteLine($"Retrieved {getResponse.Result?.Data.Count ?? 0} people."); + +// wait some time... + +getResponse = await ApiResponse.TranslateAsync(() => apiClient.GetPersonCollectionAsync(if_None_Match: eTag)); + +if (getResponse is { StatusCode: (int)HttpStatusCode.NotModified, Result: null }) +{ + Console.WriteLine("The HTTP response hasn't changed, so no response body was returned."); +} +``` + +The response of the first API call contains both data and an ETag header, which is a fingerprint of the response. +That ETag gets passed to the second API call. This enables the server to detect if something changed, which optimizes +network usage: no data is sent back, unless is has changed. +If you only want to ask whether data has changed without fetching it, use a HEAD request instead. + +# [Kiota](#tab/kiota) + +Use `HeadersInspectionHandlerOption` to gain access to HTTP response headers. For example: + +```c# +var headerInspector = new HeadersInspectionHandlerOption +{ + InspectResponseHeaders = true +}; + +var responseDocument = await apiClient.Api.People.GetAsync(configuration => configuration.Options.Add(headerInspector)); + +string eTag = headerInspector.ResponseHeaders["ETag"].Single(); +``` + +Due to a [bug in Kiota](https://github.com/microsoft/kiota/issues/4190), a try/catch block is needed additionally to make this work. + +For a full example, see the [example project](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/src/Examples/OpenApiKiotaClientExample). + +--- + +## Atomic operations + +# [NSwag](#tab/nswag) + +[Atomic operations](~/usage/writing/bulk-batch-operations.md) are fully supported. +The [example project](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/src/Examples/OpenApiNSwagClientExample) +demonstrates how to use them. It uses local IDs to: +- Create a new tag +- Create a new person +- Update the person to clear an attribute (using `TrackChangesFor`) +- Create a new todo-item, tagged with the new tag, and owned by the new person +- Assign the todo-item to the created person + +# [Kiota](#tab/kiota) + +[Atomic operations](~/usage/writing/bulk-batch-operations.md) are fully supported. +See the [example project](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/src/Examples/OpenApiKiotaClientExample) +demonstrates how to use them. It uses local IDs to: +- Create a new tag +- Create a new person +- Update the person to clear an attribute (using built-in backing-store) +- Create a new todo-item, tagged with the new tag, and owned by the new person +- Assign the todo-item to the created person + +--- + +## Known limitations + +# [NSwag](#tab/nswag) + +| Limitation | Workaround | Links | +| --- | --- | --- | +| Partial POST/PATCH sends incorrect request | Use `TrackChangesFor` from `JsonApiDotNetCore.OpenApi.Client.NSwag` package | | +| Exception thrown on successful HTTP status | Use `TranslateAsync` from `JsonApiDotNetCore.OpenApi.Client.NSwag` package | https://github.com/RicoSuter/NSwag/issues/2499 | +| No `Accept` header sent when only error responses define `Content-Type` | `JsonApiDotNetCore.OpenApi.Swashbuckle` package contains workaround | | +| Schema type not always inferred with `allOf` | `JsonApiDotNetCore.OpenApi.Swashbuckle` package contains workaround | | +| Generated code for JSON:API extensions does not compile | `JsonApiDotNetCore.OpenApi.Swashbuckle` package contains workaround | | +| A project can't contain both JSON:API clients and regular OpenAPI clients | Use separate projects | | + +# [Kiota](#tab/kiota) + +| Limitation | Workaround | Links | +| --- | --- | --- | +| Properties are always nullable | - | https://github.com/microsoft/kiota/issues/3911 | +| JSON:API query strings are inaccessible | Use `SetQueryStringHttpMessageHandler.CreateScope` from `JsonApiDotNetCore.OpenApi.Client.Kiota` package | https://github.com/microsoft/kiota/issues/3800 | +| HTTP 304 (Not Modified) is not properly recognized | Catch `ApiException` and inspect the response status code | https://github.com/microsoft/kiota/issues/4190, https://github.com/microsoft/kiota-dotnet/issues/531 | +| Generator warns about unsupported formats | Use `JsonApiDotNetCore.OpenApi.Client.Kiota` package | https://github.com/microsoft/kiota/issues/4227 | +| `Stream` response for HEAD request | - | https://github.com/microsoft/kiota/issues/4245 | +| Unhelpful exception messages | - | https://github.com/microsoft/kiota/issues/4349 | +| Discriminator properties aren't being set automatically | - | https://github.com/microsoft/kiota/issues/4618 | +| Discriminator mappings must be repeated in every derived type used in responses | `JsonApiDotNetCore.OpenApi.Swashbuckle` package contains workaround | https://github.com/microsoft/kiota/issues/2432 | +| `x-abstract` in `openapi.json` is ignored | - | | +| No MSBuild / IDE support | Use `KiotaReference` from `JsonApiDotNetCore.OpenApi.Client.Kiota` package | https://github.com/microsoft/kiota/issues/3005 | +| Incorrect nullability in API methods | Use `KiotaReference` from `JsonApiDotNetCore.OpenApi.Client.Kiota` package | https://github.com/microsoft/kiota/issues/3944 | +| Generated code for JSON:API extensions does not compile | `JsonApiDotNetCore.OpenApi.Swashbuckle` package contains workaround | | +| Properties are always sent in alphabetic order | - | https://github.com/microsoft/kiota/issues/4680 | + +--- diff --git a/docs/usage/openapi-documentation.md b/docs/usage/openapi-documentation.md new file mode 100644 index 0000000000..6737bd6404 --- /dev/null +++ b/docs/usage/openapi-documentation.md @@ -0,0 +1,48 @@ +> [!WARNING] +> OpenAPI support for JSON:API is currently experimental. The API and the structure of the OpenAPI document may change in future versions. + +# OpenAPI documentation + +After [enabling OpenAPI](~/usage/openapi.md), you can expose a documentation website with SwaggerUI, Redoc and/or Scalar. + +## SwaggerUI + +[SwaggerUI](https://swagger.io/tools/swagger-ui/) enables to visualize and interact with the JSON:API endpoints through a web page. +While it conveniently provides the ability to execute requests, it doesn't show properties of derived types when component schema inheritance is used. + +SwaggerUI can be enabled by installing the `Swashbuckle.AspNetCore.SwaggerUI` NuGet package and adding the following to your `Program.cs` file: + +```c# +app.UseSwaggerUI(); +``` + +Then run your app and open `/swagger` in your browser. + +## Redoc + +[Redoc](https://github.com/Redocly/redoc) is another popular tool that generates a documentation website from an OpenAPI document. +It lists the endpoints and their schemas, but doesn't provide the ability to execute requests. +However, this tool most accurately reflects properties when component schema inheritance is used; choosing a different "type" from the +dropdown box dynamically adapts the list of schema properties. + +The `Swashbuckle.AspNetCore.ReDoc` NuGet package provides integration with Swashbuckle. +After installing the package, add the following to your `Program.cs` file: + +```c# +app.UseReDoc(); +``` + +Next, run your app and navigate to `/api-docs` to view the documentation. + +## Scalar + +[Scalar](https://scalar.com/) is a modern documentation website generator, which includes the ability to execute requests. +It shows component schemas in a low-level way (not collapsing `allOf` nodes), but does a poor job in handling component schema inheritance. + +After installing the `Scalar.AspNetCore` NuGet package, add the following to your `Program.cs` to make it use the OpenAPI document produced by Swashbuckle: + +```c# +app.MapScalarApiReference(options => options.OpenApiRoutePattern = "/swagger/{documentName}/swagger.json"); +``` + +Then run your app and navigate to `/scalar/v1` to view the documentation. diff --git a/docs/usage/openapi.md b/docs/usage/openapi.md new file mode 100644 index 0000000000..83f3ce8a3b --- /dev/null +++ b/docs/usage/openapi.md @@ -0,0 +1,83 @@ +> [!WARNING] +> OpenAPI support for JSON:API is currently experimental. The API and the structure of the OpenAPI document may change in future versions. + +# OpenAPI + +Exposing an [OpenAPI document](https://swagger.io/specification/) for your JSON:API endpoints enables to provide a +[documentation website](https://swagger.io/tools/swagger-ui/) and to generate typed +[client libraries](https://openapi-generator.tech/docs/generators/) in various languages. + +The [JsonApiDotNetCore.OpenApi.Swashbuckle](https://github.com/json-api-dotnet/JsonApiDotNetCore/pkgs/nuget/JsonApiDotNetCore.OpenApi.Swashbuckle) NuGet package +provides OpenAPI support for JSON:API by integrating with [Swashbuckle](https://github.com/domaindrivendev/Swashbuckle.AspNetCore). + +## Getting started + +1. Install the `JsonApiDotNetCore.OpenApi.Swashbuckle` NuGet package: + + ``` + dotnet add package JsonApiDotNetCore.OpenApi.Swashbuckle --prerelease + ``` + +2. Add the JSON:API support to your `Program.cs` file. + + ```c# + builder.Services.AddJsonApi(); + + // Configure Swashbuckle for JSON:API. + builder.Services.AddOpenApiForJsonApi(); + + var app = builder.Build(); + + app.UseRouting(); + app.UseJsonApi(); + + // Add the Swashbuckle middleware. + app.UseSwagger(); + ``` + +By default, the OpenAPI document will be available at `http://localhost:/swagger/v1/swagger.json`. + +### Customizing the Route Template + +Because Swashbuckle doesn't properly implement the ASP.NET Options pattern, you must *not* use its +[documented way](https://github.com/domaindrivendev/Swashbuckle.AspNetCore?tab=readme-ov-file#change-the-path-for-swagger-json-endpoints) +to change the route template: + +```c# +// DO NOT USE THIS! INCOMPATIBLE WITH JSON:API! +app.UseSwagger(options => options.RouteTemplate = "api-docs/{documentName}/swagger.yaml"); +``` + +Instead, always call `UseSwagger()` *without parameters*. To change the route template, use the code below: + +```c# +builder.Services.Configure(options => options.RouteTemplate = "/api-docs/{documentName}/swagger.yaml"); +``` + +If you want to inject dependencies to set the route template, use: + +```c# +builder.Services.AddOptions().Configure((options, serviceProvider) => +{ + var webHostEnvironment = serviceProvider.GetRequiredService(); + string appName = webHostEnvironment.ApplicationName; + options.RouteTemplate = $"/api-docs/{{documentName}}/{appName}-swagger.yaml"; +}); +``` + +## Triple-slash comments + +Documentation for JSON:API endpoints is provided out of the box, which shows in SwaggerUI and through IDE IntelliSense in auto-generated clients. +To also get documentation for your resource classes and their properties, add the following to your project file. +The `NoWarn` line is optional, which suppresses build warnings for undocumented types and members. + +```xml + + True + $(NoWarn);1591 + +``` + +You can combine this with the documentation that Swagger itself supports, by enabling it as described +[here](https://github.com/domaindrivendev/Swashbuckle.AspNetCore#include-descriptions-from-xml-comments). +This adds documentation for additional types, such as triple-slash comments on enums used in your resource models. diff --git a/docs/usage/options.md b/docs/usage/options.md index 83d535bce4..7e89ff0090 100644 --- a/docs/usage/options.md +++ b/docs/usage/options.md @@ -10,21 +10,44 @@ 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 paging, set it to `null`. +The default page size used for all resources can be overridden in options (10 by default). To disable pagination, set it to `null`. The maximum page size and number allowed from client requests can be set too (unconstrained by default). -You can also include the total number of resources in each response. Note that when using this feature, it does add some query overhead since we have to also request the total number of resources. + +You can also include the total number of resources in each response. + +> [!NOTE] +> Including the total number of resources adds some overhead, because the count is fetched in a separate query. ```c# options.DefaultPageSize = new PageSize(25); @@ -34,11 +57,11 @@ options.IncludeTotalResourceCount = true; ``` To retrieve the total number of resources on secondary and relationship endpoints, the reverse of the relationship must to be available. For example, in `GET /customers/1/orders`, both the relationships `[HasMany] Customer.Orders` and `[HasOne] Order.Customer` must be defined. -If `IncludeTotalResourceCount` is set to `false` (or the inverse relationship is unavailable on a non-primary endpoint), best-effort paging links are returned instead. This means no `last` link and the `next` link only occurs when the current page is full. +If `IncludeTotalResourceCount` is set to `false` (or the inverse relationship is unavailable on a non-primary endpoint), best-effort pagination links are returned instead. This means no `last` link and the `next` link only occurs when the current page is full. ## Relative Links -All links are absolute by default. However, you can configure relative links. +All links are absolute by default. However, you can configure relative links: ```c# options.UseRelativeLinks = true; @@ -51,8 +74,8 @@ options.UseRelativeLinks = true; "relationships": { "author": { "links": { - "self": "/api/v1/articles/4309/relationships/author", - "related": "/api/v1/articles/4309/author" + "self": "/articles/4309/relationships/author", + "related": "/articles/4309/author" } } } @@ -99,7 +122,7 @@ Because we copy resource properties into an intermediate object before serializa ## ModelState Validation -[ASP.NET ModelState validation](https://docs.microsoft.com/en-us/aspnet/core/mvc/models/validation) can be used to validate incoming request bodies when creating and updating resources. Since v5.0, this is enabled by default. +[ASP.NET ModelState validation](https://learn.microsoft.com/aspnet/web-api/overview/formats-and-model-binding/model-validation-in-aspnet-web-api) can be used to validate incoming request bodies when creating and updating resources. Since v5.0, this is enabled by default. When `ValidateModelState` is set to `false`, no model validation is performed. How nullability affects ModelState validation is described [here](~/usage/resources/nullability.md). diff --git a/docs/usage/reading/filtering.md b/docs/usage/reading/filtering.md index a1c1215ccd..05c3066644 100644 --- a/docs/usage/reading/filtering.md +++ b/docs/usage/reading/filtering.md @@ -60,7 +60,7 @@ The next request returns all customers that have orders -or- whose last name is GET /customers?filter=has(orders)&filter=equals(lastName,'Smith') HTTP/1.1 ``` -Aside from filtering on the resource being requested (which would be blogs in /blogs and articles in /blogs/1/articles), +Aside from filtering on the resource being requested (which would be blogs in /blogs and articles in /blogs/1/articles), filtering on to-many relationships can be done using bracket notation: ```http @@ -69,7 +69,8 @@ GET /articles?include=author,tags&filter=equals(author.lastName,'Smith')&filter[ In the above request, the first filter is applied on the collection of articles, while the second one is applied on the nested collection of tags. -Note this does **not** hide articles without any matching tags! Use the `has` function with a filter condition (see below) to accomplish that. +> [!WARNING] +> The request above does **not** hide articles without any matching tags! Use the `has` function with a filter condition (see below) to accomplish that. Putting it all together, you can build quite complex filters, such as: @@ -196,7 +197,7 @@ matchTextExpression: ( 'contains' | 'startsWith' | 'endsWith' ) LPAREN fieldChain COMMA literalConstant RPAREN; anyExpression: - 'any' LPAREN fieldChain COMMA literalConstant ( COMMA literalConstant )+ RPAREN; + 'any' LPAREN fieldChain ( COMMA literalConstant )+ RPAREN; hasExpression: 'has' LPAREN fieldChain ( COMMA filterExpression )? RPAREN; diff --git a/docs/usage/reading/including-relationships.md b/docs/usage/reading/including-relationships.md index f22d2321aa..0b69a007c1 100644 --- a/docs/usage/reading/including-relationships.md +++ b/docs/usage/reading/including-relationships.md @@ -1,6 +1,6 @@ # Including Relationships -JsonApiDotNetCore supports [request include params](http://jsonapi.org/format/#fetching-includes) out of the box, +JsonApiDotNetCore supports [request include params](https://jsonapi.org/format/#fetching-includes) out of the box, for side-loading related resources. ```http diff --git a/docs/usage/reading/sparse-fieldset-selection.md b/docs/usage/reading/sparse-fieldset-selection.md index 5c08bc6ae4..6491cb050b 100644 --- a/docs/usage/reading/sparse-fieldset-selection.md +++ b/docs/usage/reading/sparse-fieldset-selection.md @@ -36,7 +36,8 @@ Example for both top-level and relationship: GET /articles?include=author&fields[articles]=title,body,author&fields[authors]=name HTTP/1.1 ``` -Note that in the last example, the `author` relationship is also added to the `articles` fieldset, so that the relationship from article to author is returned. +> [!NOTE] +> In the last example, the `author` relationship is also added to the `articles` fieldset, so that the relationship from article to author is returned. When omitted, you'll get the included resources returned, but without full resource linkage (as described [here](https://jsonapi.org/examples/#sparse-fieldsets)). ## Overriding diff --git a/docs/usage/reading/toc.yml b/docs/usage/reading/toc.yml new file mode 100644 index 0000000000..aa1ecb6bca --- /dev/null +++ b/docs/usage/reading/toc.yml @@ -0,0 +1,10 @@ +- name: Filtering + href: filtering.md +- name: Sorting + href: sorting.md +- name: Pagination + href: pagination.md +- name: Sparse Fieldset Selection + href: sparse-fieldset-selection.md +- name: Including Related Resources + href: including-relationships.md diff --git a/docs/usage/resource-graph.md b/docs/usage/resource-graph.md index 4010cbea5f..046daaf7f5 100644 --- a/docs/usage/resource-graph.md +++ b/docs/usage/resource-graph.md @@ -3,7 +3,8 @@ The `ResourceGraph` is a map of all the JSON:API resources and their relationships that your API serves. It is built at app startup and available as a singleton through Dependency Injection. -**Note:** Prior to v4 this was called the `ContextGraph`. +> [!NOTE] +> Prior to v4, this was called the `ContextGraph`. ## Constructing The Graph @@ -13,7 +14,7 @@ There are three ways the resource graph can be created: 2. Specifying an entire DbContext 3. Manually specifying each resource -It is also possible to combine the three of them at once. Be aware that some configuration might overlap, +It is also possible to combine the three of them at once. Be aware that some configuration might overlap, for example one could manually add a resource to the graph which is also auto-discovered. In such a scenario, the configuration is prioritized by the list above in descending order. @@ -27,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 @@ -43,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/docs/usage/resources/attributes.md b/docs/usage/resources/attributes.md index 669dba0892..77c6ff9566 100644 --- a/docs/usage/resources/attributes.md +++ b/docs/usage/resources/attributes.md @@ -43,9 +43,10 @@ options.DefaultAttrCapabilities = AttrCapabilities.None; // default: All This can be overridden per attribute. -### Viewability +### AllowView -Attributes can be marked to allow returning their value in responses. When not allowed and requested using `?fields[]=`, it results in an HTTP 400 response. +Indicates whether the attribute value can be returned in responses. When not allowed and requested using `?fields[]=`, it results in an HTTP 400 response. +Otherwise, the attribute is silently omitted. ```c# #nullable enable @@ -57,45 +58,59 @@ public class User : Identifiable } ``` -### Creatability +### AllowFilter -Attributes can be marked as creatable, which will allow `POST` requests to assign a value to them. When sent but not allowed, an HTTP 422 response is returned. +Indicates whether the attribute can be filtered on. When not allowed and used in `?filter=`, an HTTP 400 is returned. ```c# #nullable enable public class Person : Identifiable { - [Attr(Capabilities = AttrCapabilities.AllowCreate)] - public string? CreatorName { get; set; } + [Attr(Capabilities = AttrCapabilities.AllowFilter)] + public string? FirstName { get; set; } } ``` -### Changeability +### AllowSort -Attributes can be marked as changeable, which will allow `PATCH` requests to update them. When sent but not allowed, an HTTP 422 response is returned. +Indicates whether the attribute can be sorted on. When not allowed and used in `?sort=`, an HTTP 400 is returned. ```c# #nullable enable public class Person : Identifiable { - [Attr(Capabilities = AttrCapabilities.AllowChange)] - public string? FirstName { get; set; }; + [Attr(Capabilities = ~AttrCapabilities.AllowSort)] + public string? FirstName { get; set; } } ``` -### Filter/Sort-ability +### AllowCreate -Attributes can be marked to allow filtering and/or sorting. When not allowed, it results in an HTTP 400 response. +Indicates whether POST requests can assign the attribute value. When sent but not allowed, an HTTP 422 response is returned. ```c# #nullable enable public class Person : Identifiable { - [Attr(Capabilities = AttrCapabilities.AllowSort | AttrCapabilities.AllowFilter)] - public string? FirstName { get; set; } + [Attr(Capabilities = AttrCapabilities.AllowCreate)] + public string? CreatorName { get; set; } +} +``` + +### AllowChange + +Indicates whether PATCH requests can update the attribute value. When sent but not allowed, an HTTP 422 response is returned. + +```c# +#nullable enable + +public class Person : Identifiable +{ + [Attr(Capabilities = AttrCapabilities.AllowChange)] + public string? FirstName { get; set; }; } ``` diff --git a/docs/usage/resources/index.md b/docs/usage/resources/index.md index 552b3886fa..09e0224c57 100644 --- a/docs/usage/resources/index.md +++ b/docs/usage/resources/index.md @@ -8,7 +8,8 @@ public class Person : Identifiable } ``` -**Note:** Earlier versions of JsonApiDotNetCore allowed a short-hand notation when `TId` is of type `int`. This was removed in v5. +> [!NOTE] +> Earlier versions of JsonApiDotNetCore allowed a short-hand notation when `TId` is of type `int`. This was removed in v5. If you need to attach annotations or attributes on the `Id` property, you can override the virtual property. @@ -21,10 +22,9 @@ public class Person : Identifiable } ``` -If your resource must inherit from another class, -you can always implement the interface yourself. -In this example, `ApplicationUser` inherits from `IdentityUser` -which already contains an Id property of type string. +If your resource must inherit from another class, you can always implement the interface yourself. +In this example, `ApplicationUser` inherits from [`IdentityUser`](https://learn.microsoft.com/dotnet/api/microsoft.aspnetcore.identity.entityframeworkcore.identityuser), +which already contains an `Id` property of type `string`. ```c# public class ApplicationUser : IdentityUser, IIdentifiable diff --git a/docs/usage/resources/inheritance.md b/docs/usage/resources/inheritance.md index 47cf85ca67..56c046ef82 100644 --- a/docs/usage/resources/inheritance.md +++ b/docs/usage/resources/inheritance.md @@ -143,7 +143,7 @@ GET /humans HTTP/1.1 } ``` -### Spare fieldsets +### Sparse fieldsets If you only want to retrieve the fields from the base type, you can use [sparse fieldsets](~/usage/reading/sparse-fieldset-selection.md). diff --git a/docs/usage/resources/nullability.md b/docs/usage/resources/nullability.md index 24b15572fc..875b133a01 100644 --- a/docs/usage/resources/nullability.md +++ b/docs/usage/resources/nullability.md @@ -24,7 +24,7 @@ This makes Entity Framework Core generate non-nullable columns. And model errors # Reference types -When the [nullable reference types](https://docs.microsoft.com/en-us/dotnet/csharp/nullable-references) (NRT) compiler feature is enabled, it affects both ASP.NET ModelState validation and Entity Framework Core. +When the [nullable reference types](https://learn.microsoft.com/dotnet/csharp/nullable-references) (NRT) compiler feature is enabled, it affects both ASP.NET ModelState validation and Entity Framework Core. ## NRT turned off @@ -60,7 +60,7 @@ public sealed class Label : Identifiable When NRT is turned on, use nullability annotations (?) on attributes and relationships. This makes Entity Framework Core generate non-nullable columns. And model errors are returned when non-nullable fields are omitted. -The [Entity Framework Core guide on NRT](https://docs.microsoft.com/en-us/ef/core/miscellaneous/nullable-reference-types) recommends to use constructor binding to initialize non-nullable properties, but JsonApiDotNetCore does not support that. For required navigation properties, it suggests to use a non-nullable property with a nullable backing field. JsonApiDotNetCore does not support that either. In both cases, just use the null-forgiving operator (!). +The [Entity Framework Core guide on NRT](https://learn.microsoft.com/ef/core/miscellaneous/nullable-reference-types) recommends to use constructor binding to initialize non-nullable properties, but JsonApiDotNetCore does not support that. For required navigation properties, it suggests to use a non-nullable property with a nullable backing field. JsonApiDotNetCore does not support that either. In both cases, just use the null-forgiving operator (!). When ModelState validation is turned on, to-many relationships must be assigned an empty collection. Otherwise an error is returned when they don't occur in the request body. diff --git a/docs/usage/resources/relationships.md b/docs/usage/resources/relationships.md index 8776041e98..f318b2ddcd 100644 --- a/docs/usage/resources/relationships.md +++ b/docs/usage/resources/relationships.md @@ -1,9 +1,9 @@ # Relationships A relationship is a named link between two resource types, including a direction. -They are similar to [navigation properties in Entity Framework Core](https://docs.microsoft.com/en-us/ef/core/modeling/relationships). +They are similar to [navigation properties in Entity Framework Core](https://learn.microsoft.com/ef/core/modeling/relationships). -Relationships come in three flavors: to-one, to-many and many-to-many. +Relationships come in two flavors: to-one and to-many. The left side of a relationship is where the relationship is declared, the right side is the resource type it points to. ## HasOne @@ -22,10 +22,14 @@ public class TodoItem : Identifiable The left side of this relationship is of type `TodoItem` (public name: "todoItems") and the right side is of type `Person` (public name: "persons"). -### Required one-to-one relationships in Entity Framework Core +### One-to-one relationships in Entity Framework Core -By default, Entity Framework Core generates an identifying foreign key for a required 1-to-1 relationship. -This means no foreign key column is generated, instead the primary keys point to each other directly. +By default, Entity Framework Core tries to generate an *identifying foreign key* for a one-to-one relationship whenever possible. +In that case, no foreign key column is generated. Instead the primary keys point to each other directly. + +**That mechanism does not make sense for JSON:API, because patching a relationship would result in also +changing the identity of a resource. Naming the foreign key explicitly fixes the problem, which enforces +to create a foreign key column.** The next example defines that each car requires an engine, while an engine is optionally linked to a car. @@ -51,18 +55,19 @@ public sealed class AppDbContext : DbContext builder.Entity() .HasOne(car => car.Engine) .WithOne(engine => engine.Car) - .HasForeignKey() - .IsRequired(); + .HasForeignKey(); } } ``` Which results in Entity Framework Core generating the next database objects: + ```sql CREATE TABLE "Engine" ( "Id" integer GENERATED BY DEFAULT AS IDENTITY, CONSTRAINT "PK_Engine" PRIMARY KEY ("Id") ); + CREATE TABLE "Cars" ( "Id" integer NOT NULL, CONSTRAINT "PK_Cars" PRIMARY KEY ("Id"), @@ -71,9 +76,7 @@ CREATE TABLE "Cars" ( ); ``` -That mechanism does not make sense for JSON:API, because patching a relationship would result in also -changing the identity of a resource. Naming the foreign key explicitly fixes the problem by forcing to -create a foreign key column. +To fix this, name the foreign key explicitly: ```c# protected override void OnModelCreating(ModelBuilder builder) @@ -81,17 +84,18 @@ protected override void OnModelCreating(ModelBuilder builder) builder.Entity() .HasOne(car => car.Engine) .WithOne(engine => engine.Car) - .HasForeignKey("EngineId") // Explicit foreign key name added - .IsRequired(); + .HasForeignKey("EngineId"); // <-- Explicit foreign key name added } ``` Which generates the correct database objects: + ```sql CREATE TABLE "Engine" ( "Id" integer GENERATED BY DEFAULT AS IDENTITY, CONSTRAINT "PK_Engine" PRIMARY KEY ("Id") ); + CREATE TABLE "Cars" ( "Id" integer GENERATED BY DEFAULT AS IDENTITY, "EngineId" integer NOT NULL, @@ -99,6 +103,99 @@ CREATE TABLE "Cars" ( CONSTRAINT "FK_Cars_Engine_EngineId" FOREIGN KEY ("EngineId") REFERENCES "Engine" ("Id") ON DELETE CASCADE ); + +CREATE UNIQUE INDEX "IX_Cars_EngineId" ON "Cars" ("EngineId"); +``` + +#### Optional one-to-one relationships in Entity Framework Core + +For optional one-to-one relationships, Entity Framework Core uses `DeleteBehavior.ClientSetNull` by default, instead of `DeleteBehavior.SetNull`. +This means that Entity Framework Core tries to handle the cascading effects (by sending multiple SQL statements), instead of leaving it up to the database. +Of course that's only going to work when all the related resources are loaded in the change tracker upfront, which is expensive because it requires fetching more data than necessary. + +The reason for this odd default is poor support in SQL Server, as explained [here](https://stackoverflow.com/questions/54326165/ef-core-why-clientsetnull-is-default-ondelete-behavior-for-optional-relations) and [here](https://learn.microsoft.com/ef/core/saving/cascade-delete#database-cascade-limitations). + +**Our [testing](https://github.com/json-api-dotnet/JsonApiDotNetCore/pull/1205) shows that these limitations don't exist when using PostgreSQL. +Therefore the general advice is to map the delete behavior of optional one-to-one relationships explicitly with `.OnDelete(DeleteBehavior.SetNull)`. This is simpler and more efficient.** + +The next example defines that each car optionally has an engine, while an engine is optionally linked to a car. + +```c# +#nullable enable + +public sealed class Car : Identifiable +{ + [HasOne] + public Engine? Engine { get; set; } +} + +public sealed class Engine : Identifiable +{ + [HasOne] + public Car? Car { get; set; } +} + +public sealed class AppDbContext : DbContext +{ + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasOne(car => car.Engine) + .WithOne(engine => engine.Car) + .HasForeignKey("EngineId"); + } +} +``` + +Which results in Entity Framework Core generating the next database objects: + +```sql +CREATE TABLE "Engines" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + CONSTRAINT "PK_Engines" PRIMARY KEY ("Id") +); + +CREATE TABLE "Cars" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + "EngineId" integer NULL, + CONSTRAINT "PK_Cars" PRIMARY KEY ("Id"), + CONSTRAINT "FK_Cars_Engines_EngineId" FOREIGN KEY ("EngineId") REFERENCES "Engines" ("Id") +); + +CREATE UNIQUE INDEX "IX_Cars_EngineId" ON "Cars" ("EngineId"); +``` + +To fix this, set the delete behavior explicitly: + +``` +public sealed class AppDbContext : DbContext +{ + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasOne(car => car.Engine) + .WithOne(engine => engine.Car) + .HasForeignKey("EngineId") + .OnDelete(DeleteBehavior.SetNull); // <-- Explicit delete behavior set + } +} +``` + +Which generates the correct database objects: + +```sql +CREATE TABLE "Engines" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + CONSTRAINT "PK_Engines" PRIMARY KEY ("Id") +); + +CREATE TABLE "Cars" ( + "Id" integer GENERATED BY DEFAULT AS IDENTITY, + "EngineId" integer NULL, + CONSTRAINT "PK_Cars" PRIMARY KEY ("Id"), + CONSTRAINT "FK_Cars_Engines_EngineId" FOREIGN KEY ("EngineId") REFERENCES "Engines" ("Id") ON DELETE SET NULL +); + CREATE UNIQUE INDEX "IX_Cars_EngineId" ON "Cars" ("EngineId"); ``` @@ -160,7 +257,112 @@ public class TodoItem : Identifiable } ``` -## Includibility +## Capabilities + +_since v5.1_ + +Default JSON:API relationship capabilities are specified in +@JsonApiDotNetCore.Configuration.JsonApiOptions#JsonApiDotNetCore_Configuration_JsonApiOptions_DefaultHasOneCapabilities and +@JsonApiDotNetCore.Configuration.JsonApiOptions#JsonApiDotNetCore_Configuration_JsonApiOptions_DefaultHasManyCapabilities: + +```c# +options.DefaultHasOneCapabilities = HasOneCapabilities.None; // default: All +options.DefaultHasManyCapabilities = HasManyCapabilities.None; // default: All +``` + +This can be overridden per relationship. + +### AllowView + +Indicates whether the relationship can be returned in responses. When not allowed and requested using `?fields[]=`, it results in an HTTP 400 response. +Otherwise, the relationship (and its related resources, when included) are silently omitted. + +> [!WARNING] +> This setting does not affect retrieving the related resources directly. + +```c# +#nullable enable + +public class User : Identifiable +{ + [HasOne(Capabilities = ~HasOneCapabilities.AllowView)] + public LoginAccount Account { get; set; } = null!; +} +``` + +### AllowInclude + +Indicates whether the relationship can be included. When not allowed and used in `?include=`, an HTTP 400 is returned. + +```c# +#nullable enable + +public class User : Identifiable +{ + [HasMany(Capabilities = ~HasManyCapabilities.AllowInclude)] + public ISet Groups { get; set; } = new HashSet(); +} +``` + +### AllowFilter + +For to-many relationships only. Indicates whether it can be used in the `count()` and `has()` filter functions. When not allowed and used in `?filter=`, an HTTP 400 is returned. + +```c# +#nullable enable + +public class User : Identifiable +{ + [HasMany(Capabilities = HasManyCapabilities.AllowFilter)] + public ISet Groups { get; set; } = new HashSet(); +} +``` + +### AllowSet + +Indicates whether POST and PATCH requests can replace the relationship. When sent but not allowed, an HTTP 422 response is returned. + +```c# +#nullable enable + +public class User : Identifiable +{ + [HasOne(Capabilities = ~HasOneCapabilities.AllowSet)] + public LoginAccount Account { get; set; } = null!; +} +``` + +### AllowAdd + +For to-many relationships only. Indicates whether POST requests can add resources to the relationship. When sent but not allowed, an HTTP 422 response is returned. + +```c# +#nullable enable + +public class User : Identifiable +{ + [HasMany(Capabilities = ~HasManyCapabilities.AllowAdd)] + public ISet Groups { get; set; } = new HashSet(); +} +``` + +### AllowRemove + +For to-many relationships only. Indicates whether DELETE requests can remove resources from the relationship. When sent but not allowed, an HTTP 422 response is returned. + +```c# +#nullable enable + +public class User : Identifiable +{ + [HasMany(Capabilities = ~HasManyCapabilities.AllowRemove)] + public ISet Groups { get; set; } = new HashSet(); +} +``` + +## CanInclude + +_obsolete since v5.1_ Relationships can be marked to disallow including them using the `?include=` query string parameter. When not allowed, it results in an HTTP 400 response. diff --git a/docs/usage/resources/toc.yml b/docs/usage/resources/toc.yml new file mode 100644 index 0000000000..d4daf205d4 --- /dev/null +++ b/docs/usage/resources/toc.yml @@ -0,0 +1,8 @@ +- name: Attributes + href: attributes.md +- name: Relationships + href: relationships.md +- name: Inheritance + href: inheritance.md +- name: Nullability + href: nullability.md diff --git a/docs/usage/routing.md b/docs/usage/routing.md index a264622931..cb1197e86a 100644 --- a/docs/usage/routing.md +++ b/docs/usage/routing.md @@ -11,10 +11,10 @@ You can add a namespace to all URLs by specifying it at startup. ```c# // Program.cs -builder.Services.AddJsonApi(options => options.Namespace = "api/v1"); +builder.Services.AddJsonApi(options => options.Namespace = "api/shopping"); ``` -Which results in URLs like: https://yourdomain.com/api/v1/people +Which results in URLs like: https://yourdomain.com/api/shopping/articles ## Default routing convention @@ -66,14 +66,14 @@ It is possible to override the default routing convention for an auto-generated ```c# // Auto-generated [DisableRoutingConvention] -[Route("v1/custom/route/summaries-for-orders")] +[Route("custom/route/summaries-for-orders")] partial class OrderSummariesController { } // Hand-written [DisableRoutingConvention] -[Route("v1/custom/route/lines-in-order")] +[Route("custom/route/lines-in-order")] public class OrderLineController : JsonApiController { public OrderLineController(IJsonApiOptions options, IResourceGraph resourceGraph, @@ -86,7 +86,7 @@ public class OrderLineController : JsonApiController ## Advanced usage: custom routing convention -It is possible to replace the built-in routing convention with a [custom routing convention](https://docs.microsoft.com/en-us/aspnet/core/mvc/controllers/application-model?view=aspnetcore-3.1#sample-custom-routing-convention) by registering an implementation of `IJsonApiRoutingConvention`. +It is possible to replace the built-in routing convention with a [custom routing convention](https://learn.microsoft.com/aspnet/core/mvc/controllers/application-model#custom-routing-convention) by registering an implementation of `IJsonApiRoutingConvention`. ```c# // Program.cs diff --git a/docs/usage/toc.md b/docs/usage/toc.md deleted file mode 100644 index c30a2b0f37..0000000000 --- a/docs/usage/toc.md +++ /dev/null @@ -1,34 +0,0 @@ -# [Resources](resources/index.md) -## [Attributes](resources/attributes.md) -## [Relationships](resources/relationships.md) -## [Inheritance](resources/inheritance.md) -## [Nullability](resources/nullability.md) - -# Reading data -## [Filtering](reading/filtering.md) -## [Sorting](reading/sorting.md) -## [Pagination](reading/pagination.md) -## [Sparse Fieldset Selection](reading/sparse-fieldset-selection.md) -## [Including Relationships](reading/including-relationships.md) - -# Writing data -## [Creating](writing/creating.md) -## [Updating](writing/updating.md) -## [Deleting](writing/deleting.md) -## [Bulk/batch](writing/bulk-batch-operations.md) - -# [Resource Graph](resource-graph.md) -# [Options](options.md) -# [Routing](routing.md) -# [Errors](errors.md) -# [Metadata](meta.md) -# [Caching](caching.md) - -# Extensibility -## [Layer Overview](extensibility/layer-overview.md) -## [Resource Definitions](extensibility/resource-definitions.md) -## [Controllers](extensibility/controllers.md) -## [Resource Services](extensibility/services.md) -## [Resource Repositories](extensibility/repositories.md) -## [Middleware](extensibility/middleware.md) -## [Query Strings](extensibility/query-strings.md) diff --git a/docs/usage/toc.yml b/docs/usage/toc.yml new file mode 100644 index 0000000000..f5d60e9a1f --- /dev/null +++ b/docs/usage/toc.yml @@ -0,0 +1,35 @@ +- name: FAQ + href: faq.md +- name: Common Pitfalls + href: common-pitfalls.md +- name: Resources + href: resources/toc.yml + topicHref: resources/index.md +- name: Reading data + href: reading/toc.yml +- name: Writing data + href: writing/toc.yml +- name: Resource Graph + href: resource-graph.md +- name: Options + href: options.md +- name: Routing + href: routing.md +- name: Errors + href: errors.md +- name: Metadata + href: meta.md +- name: Caching + href: caching.md +- name: OpenAPI + href: openapi.md + items: + - name: Documentation + href: openapi-documentation.md + - name: Clients + href: openapi-client.md +- name: Extensibility + href: extensibility/toc.yml +- name: Advanced + href: advanced/toc.yml + topicHref: advanced/index.md diff --git a/docs/usage/writing/bulk-batch-operations.md b/docs/usage/writing/bulk-batch-operations.md index 1ac35fd3fc..c8ba2bf48e 100644 --- a/docs/usage/writing/bulk-batch-operations.md +++ b/docs/usage/writing/bulk-batch-operations.md @@ -19,13 +19,24 @@ 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) + IJsonApiRequest request, ITargetedFields targetedFields, + IAtomicOperationFilter operationFilter) + : base(options, resourceGraph, loggerFactory, processor, request, targetedFields, + operationFilter) { } } ``` +> [!IMPORTANT] +> Since v5.6.0, the set of exposed operations is based on +> [`GenerateControllerEndpoints` usage](~/usage/extensibility/controllers.md#resource-access-control). +> Earlier versions always exposed all operations for all resource types. +> If you're using [explicit controllers](~/usage/extensibility/controllers.md#explicit-controllers), +> register and implement your own +> [`IAtomicOperationFilter`](~/api/JsonApiDotNetCore.AtomicOperations.IAtomicOperationFilter.yml) +> to indicate which operations to expose. + You'll need to send the next Content-Type in a POST request for operations: ``` diff --git a/docs/usage/writing/creating.md b/docs/usage/writing/creating.md index 4cbe42602e..8cc0c03e49 100644 --- a/docs/usage/writing/creating.md +++ b/docs/usage/writing/creating.md @@ -16,8 +16,8 @@ POST /articles HTTP/1.1 } ``` -When using client-generated IDs and only attributes from the request have changed, the server returns `204 No Content`. -Otherwise, the server returns `200 OK`, along with the updated resource and its newly assigned ID. +When using client-generated IDs and all attributes of the created resource are the same as in the request, the server +returns `204 No Content`. Otherwise, the server returns `201 Created`, along with the stored attributes and its newly assigned ID. In both cases, a `Location` header is returned that contains the URL to the new resource. @@ -71,4 +71,6 @@ POST /articles?include=owner&fields[people]=firstName HTTP/1.1 } ``` -After the resource has been created on the server, it is re-fetched from the database using the specified query string parameters and returned to the client. +> [!NOTE] +> After the resource has been created on the server, it is re-fetched from the database using the specified query string parameters and returned to the client. +> However, the used query string parameters only have an effect when `200 OK` is returned. diff --git a/docs/usage/writing/toc.yml b/docs/usage/writing/toc.yml new file mode 100644 index 0000000000..db836e548f --- /dev/null +++ b/docs/usage/writing/toc.yml @@ -0,0 +1,8 @@ +- name: Creating + href: creating.md +- name: Updating + href: updating.md +- name: Deleting + href: deleting.md +- name: Bulk/Batch + href: bulk-batch-operations.md diff --git a/docs/usage/writing/updating.md b/docs/usage/writing/updating.md index 132d487cfe..30e1b4fa7d 100644 --- a/docs/usage/writing/updating.md +++ b/docs/usage/writing/updating.md @@ -5,7 +5,7 @@ To modify the attributes of a single resource, send a PATCH request. The next example changes the article caption: ```http -POST /articles HTTP/1.1 +PATCH /articles/1 HTTP/1.1 { "data": { @@ -21,12 +21,14 @@ POST /articles HTTP/1.1 This preserves the values of all other unsent attributes and is called a *partial patch*. When only the attributes that were sent in the request have changed, the server returns `204 No Content`. -But if additional attributes have changed (for example, by a database trigger that refreshes the last-modified date) the server returns `200 OK`, along with all attributes of the updated resource. +But if additional attributes have changed (for example, by a database trigger or [resource definition](~/usage/extensibility/resource-definitions.md) that refreshes the last-modified date) the server returns `200 OK`, along with all attributes of the updated resource. ## Updating resource relationships Besides its attributes, the relationships of a resource can be changed using a PATCH request too. -Note that all resources being assigned in a relationship must already exist. + +> [!NOTE] +> All resources being assigned in a relationship must already exist. When updating a HasMany relationship, the existing set is replaced by the new set. See below on how to add/remove resources. @@ -65,7 +67,8 @@ PATCH /articles/1 HTTP/1.1 A HasOne relationship can be cleared by setting `data` to `null`, while a HasMany relationship can be cleared by setting it to an empty array. -By combining the examples above, both attributes and relationships can be updated using a single PATCH request. +> [!TIP] +> By combining the examples above, both attributes and relationships can be updated using a single PATCH request. ## Response body @@ -79,8 +82,9 @@ PATCH /articles/1?include=owner&fields[people]=firstName HTTP/1.1 } ``` -After the resource has been updated on the server, it is re-fetched from the database using the specified query string parameters and returned to the client. -Note this only has an effect when `200 OK` is returned. +> [!NOTE] +> After the resource has been updated on the server, it is re-fetched from the database using the specified query string parameters and returned to the client. +> However, the used query string parameters only have an effect when `200 OK` is returned. # Updating relationships diff --git a/inspectcode.ps1 b/inspectcode.ps1 index 16dccfd373..21e96eac67 100644 --- a/inspectcode.ps1 +++ b/inspectcode.ps1 @@ -4,16 +4,16 @@ dotnet tool restore -if ($LASTEXITCODE -ne 0) { - throw "Tool restore failed with exit code $LASTEXITCODE" +if ($LastExitCode -ne 0) { + throw "Tool restore failed with exit code $LastExitCode" } $outputPath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), 'jetbrains-inspectcode-results.xml') $resultPath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), 'jetbrains-inspectcode-results.html') -dotnet jb inspectcode JsonApiDotNetCore.sln --build --output="$outputPath" --profile=WarningSeverities.DotSettings --properties:Configuration=Release --severity=WARNING --verbosity=WARN -dsl=GlobalAll -dsl=GlobalPerProduct -dsl=SolutionPersonal -dsl=ProjectPersonal +dotnet jb inspectcode JsonApiDotNetCore.sln --dotnetcoresdk=$(dotnet --version) --build --output="$outputPath" --format="xml" --profile=WarningSeverities.DotSettings --properties:Configuration=Release --properties:RunAnalyzers=false --severity=WARNING --verbosity=WARN -dsl=GlobalAll -dsl=GlobalPerProduct -dsl=SolutionPersonal -dsl=ProjectPersonal -if ($LASTEXITCODE -ne 0) { - throw "Code inspection failed with exit code $LASTEXITCODE" +if ($LastExitCode -ne 0) { + throw "Code inspection failed with exit code $LastExitCode" } [xml]$xml = Get-Content "$outputPath" diff --git a/logo.png b/logo.png deleted file mode 100644 index 78f1acd521..0000000000 Binary files a/logo.png and /dev/null differ diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000000..16164967c6 --- /dev/null +++ b/nuget.config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/package-icon.png b/package-icon.png new file mode 100644 index 0000000000..f95eb770e8 Binary files /dev/null and b/package-icon.png differ diff --git a/package-versions.props b/package-versions.props new file mode 100644 index 0000000000..0f2c3f6e7e --- /dev/null +++ b/package-versions.props @@ -0,0 +1,53 @@ + + + + 4.1.0 + 0.4.1 + 2.14.1 + 9.0.1 + 13.0.3 + + + 0.15.* + 1.0.* + 35.6.* + 4.14.* + 6.0.* + 2.1.* + 7.2.* + 2.4.* + 2.0.* + 1.* + 9.0.* + 9.0.* + 14.4.* + 13.0.* + 4.1.* + 2.4.* + 9.*-* + 9.0.* + 17.14.* + 2.9.* + 3.1.* + + + + + N/A + + + 9.0.* + 9.0.* + 9.0.0-* + + + + + 8.0.0 + + + 8.0.* + 8.0.* + $(EntityFrameworkCoreVersion) + + diff --git a/run-docker-postgres.ps1 b/run-docker-postgres.ps1 index 89a325e3b5..25b631a7ad 100644 --- a/run-docker-postgres.ps1 +++ b/run-docker-postgres.ps1 @@ -1,12 +1,18 @@ #Requires -Version 7.0 -# This script starts a docker container with postgres database, used for running tests. +# This script starts a PostgreSQL database in a docker container, which is required for running tests locally. +# When the -UI switch is passed, pgAdmin (a web-based PostgreSQL management tool) is started in a second container, which lets you query the database. +# To connect to pgAdmin, open http://localhost:5050 and login with user "admin@admin.com", password "postgres". Use hostname "db" when registering the server. -docker container stop jsonapi-dotnet-core-testing +param( + [switch] $UI=$False +) -docker run --rm --name jsonapi-dotnet-core-testing ` - -e POSTGRES_DB=JsonApiDotNetCoreExample ` - -e POSTGRES_USER=postgres ` - -e POSTGRES_PASSWORD=postgres ` - -p 5432:5432 ` - postgres:13.4 +docker container stop jsonapi-postgresql-db +docker container stop jsonapi-postgresql-management + +docker run --pull always --rm --detach --name jsonapi-postgresql-db -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:latest -N 500 + +if ($UI) { + docker run --pull always --rm --detach --name jsonapi-postgresql-management --link jsonapi-postgresql-db:db -e PGADMIN_DEFAULT_EMAIL=admin@admin.com -e PGADMIN_DEFAULT_PASSWORD=postgres -p 5050:80 dpage/pgadmin4:latest +} diff --git a/src/Examples/DapperExample/AtomicOperations/AmbientTransaction.cs b/src/Examples/DapperExample/AtomicOperations/AmbientTransaction.cs new file mode 100644 index 0000000000..11d052da66 --- /dev/null +++ b/src/Examples/DapperExample/AtomicOperations/AmbientTransaction.cs @@ -0,0 +1,60 @@ +using System.Data.Common; +using JsonApiDotNetCore.AtomicOperations; + +namespace DapperExample.AtomicOperations; + +/// +/// Represents an ADO.NET transaction in a JSON:API atomic:operations request. +/// +internal sealed class AmbientTransaction : IOperationsTransaction +{ + private readonly AmbientTransactionFactory _owner; + + public DbTransaction Current { get; } + + /// + public string TransactionId { get; } + + public AmbientTransaction(AmbientTransactionFactory owner, DbTransaction current, Guid transactionId) + { + ArgumentNullException.ThrowIfNull(owner); + ArgumentNullException.ThrowIfNull(current); + + _owner = owner; + Current = current; + TransactionId = transactionId.ToString(); + } + + /// + public Task BeforeProcessOperationAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + public Task AfterProcessOperationAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + public Task CommitAsync(CancellationToken cancellationToken) + { + return Current.CommitAsync(cancellationToken); + } + + /// + public async ValueTask DisposeAsync() + { + DbConnection? connection = Current.Connection; + + await Current.DisposeAsync(); + + if (connection != null) + { + await connection.DisposeAsync(); + } + + _owner.Detach(this); + } +} diff --git a/src/Examples/DapperExample/AtomicOperations/AmbientTransactionFactory.cs b/src/Examples/DapperExample/AtomicOperations/AmbientTransactionFactory.cs new file mode 100644 index 0000000000..82790819fe --- /dev/null +++ b/src/Examples/DapperExample/AtomicOperations/AmbientTransactionFactory.cs @@ -0,0 +1,77 @@ +using System.Data.Common; +using DapperExample.TranslationToSql.DataModel; +using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.Configuration; + +namespace DapperExample.AtomicOperations; + +/// +/// Provides transaction support for JSON:API atomic:operation requests using ADO.NET. +/// +public sealed class AmbientTransactionFactory : IOperationsTransactionFactory +{ + private readonly IJsonApiOptions _options; + private readonly IDataModelService _dataModelService; + + internal AmbientTransaction? AmbientTransaction { get; private set; } + + public AmbientTransactionFactory(IJsonApiOptions options, IDataModelService dataModelService) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(dataModelService); + + _options = options; + _dataModelService = dataModelService; + } + + internal async Task BeginTransactionAsync(CancellationToken cancellationToken) + { + var instance = (IOperationsTransactionFactory)this; + + IOperationsTransaction transaction = await instance.BeginTransactionAsync(cancellationToken); + return (AmbientTransaction)transaction; + } + + async Task IOperationsTransactionFactory.BeginTransactionAsync(CancellationToken cancellationToken) + { + if (AmbientTransaction != null) + { + throw new InvalidOperationException("Cannot start transaction because another transaction is already active."); + } + + DbConnection dbConnection = _dataModelService.CreateConnection(); + + try + { + await dbConnection.OpenAsync(cancellationToken); + + DbTransaction transaction = _options.TransactionIsolationLevel != null + ? await dbConnection.BeginTransactionAsync(_options.TransactionIsolationLevel.Value, cancellationToken) + : await dbConnection.BeginTransactionAsync(cancellationToken); + + var transactionId = Guid.NewGuid(); + AmbientTransaction = new AmbientTransaction(this, transaction, transactionId); + + return AmbientTransaction; + } + catch (DbException) + { + await dbConnection.DisposeAsync(); + throw; + } + } + + internal void Detach(AmbientTransaction ambientTransaction) + { + ArgumentNullException.ThrowIfNull(ambientTransaction); + + if (AmbientTransaction != null && AmbientTransaction == ambientTransaction) + { + AmbientTransaction = null; + } + else + { + throw new InvalidOperationException("Failed to detach ambient transaction."); + } + } +} diff --git a/src/Examples/DapperExample/Controllers/OperationsController.cs b/src/Examples/DapperExample/Controllers/OperationsController.cs new file mode 100644 index 0000000000..ed15c6e9a2 --- /dev/null +++ b/src/Examples/DapperExample/Controllers/OperationsController.cs @@ -0,0 +1,12 @@ +using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; + +namespace DapperExample.Controllers; + +public sealed class OperationsController( + IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request, + ITargetedFields targetedFields, IAtomicOperationFilter operationFilter) + : JsonApiOperationsController(options, resourceGraph, loggerFactory, processor, request, targetedFields, operationFilter); diff --git a/src/Examples/DapperExample/DapperExample.csproj b/src/Examples/DapperExample/DapperExample.csproj new file mode 100644 index 0000000000..ed7bd358eb --- /dev/null +++ b/src/Examples/DapperExample/DapperExample.csproj @@ -0,0 +1,21 @@ + + + net9.0;net8.0 + + + + + + + + + + + + + + + + + diff --git a/src/Examples/DapperExample/Data/AppDbContext.cs b/src/Examples/DapperExample/Data/AppDbContext.cs new file mode 100644 index 0000000000..31f09b277c --- /dev/null +++ b/src/Examples/DapperExample/Data/AppDbContext.cs @@ -0,0 +1,80 @@ +using DapperExample.Models; +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; + +// @formatter:wrap_chained_method_calls chop_always + +namespace DapperExample.Data; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class AppDbContext : DbContext +{ + private readonly IConfiguration _configuration; + + public DbSet TodoItems => Set(); + public DbSet People => Set(); + public DbSet LoginAccounts => Set(); + public DbSet AccountRecoveries => Set(); + public DbSet Tags => Set(); + public DbSet RgbColors => Set(); + + public AppDbContext(DbContextOptions options, IConfiguration configuration) + : base(options) + { + ArgumentNullException.ThrowIfNull(configuration); + + _configuration = configuration; + } + + protected override void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasMany(person => person.AssignedTodoItems) + .WithOne(todoItem => todoItem.Assignee); + + builder.Entity() + .HasMany(person => person.OwnedTodoItems) + .WithOne(todoItem => todoItem.Owner); + + builder.Entity() + .HasOne(person => person.Account) + .WithOne(loginAccount => loginAccount.Person) + .HasForeignKey("AccountId"); + + builder.Entity() + .HasOne(loginAccount => loginAccount.Recovery) + .WithOne(accountRecovery => accountRecovery.Account) + .HasForeignKey("RecoveryId"); + + builder.Entity() + .HasOne(tag => tag.Color) + .WithOne(rgbColor => rgbColor.Tag) + .HasForeignKey("TagId"); + + var databaseProvider = _configuration.GetValue("DatabaseProvider"); + + if (databaseProvider != DatabaseProvider.SqlServer) + { + // In this example project, all cascades happen in the database, but SQL Server doesn't support that very well. + AdjustDeleteBehaviorForJsonApi(builder); + } + } + + private static void AdjustDeleteBehaviorForJsonApi(ModelBuilder builder) + { + foreach (IMutableForeignKey foreignKey in builder.Model.GetEntityTypes() + .SelectMany(entityType => entityType.GetForeignKeys())) + { + if (foreignKey.DeleteBehavior == DeleteBehavior.ClientSetNull) + { + foreignKey.DeleteBehavior = DeleteBehavior.SetNull; + } + + if (foreignKey.DeleteBehavior == DeleteBehavior.ClientCascade) + { + foreignKey.DeleteBehavior = DeleteBehavior.Cascade; + } + } + } +} diff --git a/src/Examples/DapperExample/Data/RotatingList.cs b/src/Examples/DapperExample/Data/RotatingList.cs new file mode 100644 index 0000000000..278c34140a --- /dev/null +++ b/src/Examples/DapperExample/Data/RotatingList.cs @@ -0,0 +1,30 @@ +namespace DapperExample.Data; + +internal abstract class RotatingList +{ + public static RotatingList Create(int count, Func createElement) + { + List elements = []; + + for (int index = 0; index < count; index++) + { + T element = createElement(index); + elements.Add(element); + } + + return new RotatingList(elements); + } +} + +internal sealed class RotatingList(IList elements) +{ + private int _index = -1; + + public IList Elements { get; } = elements; + + public T GetNext() + { + _index++; + return Elements[_index % Elements.Count]; + } +} diff --git a/src/Examples/DapperExample/Data/Seeder.cs b/src/Examples/DapperExample/Data/Seeder.cs new file mode 100644 index 0000000000..eb86eca7e8 --- /dev/null +++ b/src/Examples/DapperExample/Data/Seeder.cs @@ -0,0 +1,94 @@ +using DapperExample.Models; +using JetBrains.Annotations; + +namespace DapperExample.Data; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +internal sealed class Seeder +{ + public static async Task CreateSampleDataAsync(AppDbContext dbContext) + { + const int todoItemCount = 500; + const int personCount = 50; + const int accountRecoveryCount = 50; + const int loginAccountCount = 50; + const int tagCount = 25; + const int colorCount = 25; + + RotatingList accountRecoveries = RotatingList.Create(accountRecoveryCount, index => new AccountRecovery + { + PhoneNumber = $"PhoneNumber{index + 1:D2}", + EmailAddress = $"EmailAddress{index + 1:D2}" + }); + + RotatingList loginAccounts = RotatingList.Create(loginAccountCount, index => new LoginAccount + { + UserName = $"UserName{index + 1:D2}", + Recovery = accountRecoveries.GetNext() + }); + + RotatingList people = RotatingList.Create(personCount, index => + { + var person = new Person + { + FirstName = $"FirstName{index + 1:D2}", + LastName = $"LastName{index + 1:D2}" + }; + + if (index % 2 == 0) + { + person.Account = loginAccounts.GetNext(); + } + + return person; + }); + + RotatingList colors = + RotatingList.Create(colorCount, index => RgbColor.Create((byte)(index % 255), (byte)(index % 255), (byte)(index % 255))); + + RotatingList tags = RotatingList.Create(tagCount, index => + { + var tag = new Tag + { + Name = $"TagName{index + 1:D2}" + }; + + if (index % 2 == 0) + { + tag.Color = colors.GetNext(); + } + + return tag; + }); + + RotatingList priorities = RotatingList.Create(3, index => (TodoItemPriority)(index + 1)); + + RotatingList todoItems = RotatingList.Create(todoItemCount, index => + { + var todoItem = new TodoItem + { + Description = $"TodoItem{index + 1:D3}", + Priority = priorities.GetNext(), + DurationInHours = index, + CreatedAt = DateTimeOffset.UtcNow, + Owner = people.GetNext(), + Tags = new HashSet + { + tags.GetNext(), + tags.GetNext(), + tags.GetNext() + } + }; + + if (index % 3 == 0) + { + todoItem.Assignee = people.GetNext(); + } + + return todoItem; + }); + + dbContext.TodoItems.AddRange(todoItems.Elements); + await dbContext.SaveChangesAsync(); + } +} diff --git a/src/Examples/DapperExample/DatabaseProvider.cs b/src/Examples/DapperExample/DatabaseProvider.cs new file mode 100644 index 0000000000..ea9c293c11 --- /dev/null +++ b/src/Examples/DapperExample/DatabaseProvider.cs @@ -0,0 +1,11 @@ +namespace DapperExample; + +/// +/// Lists the supported databases. +/// +public enum DatabaseProvider +{ + PostgreSql, + MySql, + SqlServer +} diff --git a/src/Examples/DapperExample/Definitions/TodoItemDefinition.cs b/src/Examples/DapperExample/Definitions/TodoItemDefinition.cs new file mode 100644 index 0000000000..f20268fb7d --- /dev/null +++ b/src/Examples/DapperExample/Definitions/TodoItemDefinition.cs @@ -0,0 +1,50 @@ +using System.ComponentModel; +using DapperExample.Models; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; + +namespace DapperExample.Definitions; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class TodoItemDefinition : JsonApiResourceDefinition +{ + private readonly TimeProvider _timeProvider; + + public TodoItemDefinition(IResourceGraph resourceGraph, TimeProvider timeProvider) + : base(resourceGraph) + { + ArgumentNullException.ThrowIfNull(timeProvider); + + _timeProvider = timeProvider; + } + + public override SortExpression OnApplySort(SortExpression? existingSort) + { + return existingSort ?? GetDefaultSortOrder(); + } + + private SortExpression GetDefaultSortOrder() + { + return CreateSortExpressionFromLambda([ + (todoItem => todoItem.Priority, ListSortDirection.Ascending), + (todoItem => todoItem.LastModifiedAt, ListSortDirection.Descending) + ]); + } + + public override Task OnWritingAsync(TodoItem resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (writeOperation == WriteOperationKind.CreateResource) + { + resource.CreatedAt = _timeProvider.GetUtcNow(); + } + else if (writeOperation == WriteOperationKind.UpdateResource) + { + resource.LastModifiedAt = _timeProvider.GetUtcNow(); + } + + return Task.CompletedTask; + } +} diff --git a/src/Examples/DapperExample/FromEntitiesNavigationResolver.cs b/src/Examples/DapperExample/FromEntitiesNavigationResolver.cs new file mode 100644 index 0000000000..4ae5f05dcc --- /dev/null +++ b/src/Examples/DapperExample/FromEntitiesNavigationResolver.cs @@ -0,0 +1,45 @@ +using DapperExample.Data; +using DapperExample.TranslationToSql.DataModel; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace DapperExample; + +/// +/// Resolves inverse navigations and initializes from an Entity Framework Core . +/// +internal sealed class FromEntitiesNavigationResolver : IInverseNavigationResolver +{ + private readonly InverseNavigationResolver _defaultResolver; + private readonly FromEntitiesDataModelService _dataModelService; + private readonly DbContext _appDbContext; + + public FromEntitiesNavigationResolver(IResourceGraph resourceGraph, FromEntitiesDataModelService dataModelService, AppDbContext appDbContext) + { + ArgumentNullException.ThrowIfNull(resourceGraph); + ArgumentNullException.ThrowIfNull(dataModelService); + ArgumentNullException.ThrowIfNull(appDbContext); + + _defaultResolver = new InverseNavigationResolver(resourceGraph, new[] + { + new DbContextResolver(appDbContext) + }); + + _dataModelService = dataModelService; + _appDbContext = appDbContext; + } + + public void Resolve() + { + // In order to produce SQL, some knowledge of the underlying database model is required. + // Because the database in this example project is created using Entity Framework Core, we derive that information from its model. + // Some alternative approaches to consider: + // - Query the database to obtain model information at startup. + // - Create a custom attribute that is put on [HasOne/HasMany] resource properties and scan for them at startup. + // - Hard-code the required information in the application. + + _defaultResolver.Resolve(); + _dataModelService.Initialize(_appDbContext); + } +} diff --git a/src/Examples/DapperExample/Models/AccountRecovery.cs b/src/Examples/DapperExample/Models/AccountRecovery.cs new file mode 100644 index 0000000000..38410c203c --- /dev/null +++ b/src/Examples/DapperExample/Models/AccountRecovery.cs @@ -0,0 +1,19 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class AccountRecovery : Identifiable +{ + [Attr] + public string? PhoneNumber { get; set; } + + [Attr] + public string? EmailAddress { get; set; } + + [HasOne] + public LoginAccount Account { get; set; } = null!; +} diff --git a/src/Examples/DapperExample/Models/LoginAccount.cs b/src/Examples/DapperExample/Models/LoginAccount.cs new file mode 100644 index 0000000000..149fc6c7f8 --- /dev/null +++ b/src/Examples/DapperExample/Models/LoginAccount.cs @@ -0,0 +1,21 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class LoginAccount : Identifiable +{ + [Attr] + public string UserName { get; set; } = null!; + + public DateTimeOffset? LastUsedAt { get; set; } + + [HasOne] + public AccountRecovery Recovery { get; set; } = null!; + + [HasOne] + public Person Person { get; set; } = null!; +} diff --git a/src/Examples/DapperExample/Models/Person.cs b/src/Examples/DapperExample/Models/Person.cs new file mode 100644 index 0000000000..1eb4ecadee --- /dev/null +++ b/src/Examples/DapperExample/Models/Person.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations.Schema; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class Person : Identifiable +{ + [Attr] + public string? FirstName { get; set; } + + [Attr] + public string LastName { get; set; } = null!; + + // Mistakenly includes AllowFilter, so we can test for the error produced. + [Attr(Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowFilter)] + [NotMapped] + public string DisplayName => FirstName != null ? $"{FirstName} {LastName}" : LastName; + + [HasOne] + public LoginAccount? Account { get; set; } + + [HasMany] + public ISet OwnedTodoItems { get; set; } = new HashSet(); + + [HasMany] + public ISet AssignedTodoItems { get; set; } = new HashSet(); +} diff --git a/src/Examples/DapperExample/Models/RgbColor.cs b/src/Examples/DapperExample/Models/RgbColor.cs new file mode 100644 index 0000000000..c29e1b1ba1 --- /dev/null +++ b/src/Examples/DapperExample/Models/RgbColor.cs @@ -0,0 +1,55 @@ +using System.ComponentModel.DataAnnotations.Schema; +using System.Drawing; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ClientIdGeneration = ClientIdGenerationMode.Required)] +public sealed class RgbColor : Identifiable +{ + [DatabaseGenerated(DatabaseGeneratedOption.None)] + public override int? Id + { + get => base.Id; + set => base.Id = value; + } + + [HasOne] + public Tag Tag { get; set; } = null!; + + [Attr(Capabilities = AttrCapabilities.AllowView)] + [NotMapped] + public byte? Red => Id == null ? null : (byte)((Id & 0xFF_0000) >> 16); + + [Attr(Capabilities = AttrCapabilities.AllowView)] + [NotMapped] + public byte? Green => Id == null ? null : (byte)((Id & 0x00_FF00) >> 8); + + [Attr(Capabilities = AttrCapabilities.AllowView)] + [NotMapped] + public byte? Blue => Id == null ? null : (byte)(Id & 0x00_00FF); + + public static RgbColor Create(byte red, byte green, byte blue) + { + Color color = Color.FromArgb(0xFF, red, green, blue); + + return new RgbColor + { + Id = color.ToArgb() & 0x00FF_FFFF + }; + } + + protected override string? GetStringId(int? value) + { + return value?.ToString("X6"); + } + + protected override int? GetTypedId(string? value) + { + return value == null ? null : Convert.ToInt32(value, 16) & 0xFF_FFFF; + } +} diff --git a/src/Examples/DapperExample/Models/Tag.cs b/src/Examples/DapperExample/Models/Tag.cs new file mode 100644 index 0000000000..cb49ff42fb --- /dev/null +++ b/src/Examples/DapperExample/Models/Tag.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class Tag : Identifiable +{ + [Attr] + [MinLength(1)] + public string Name { get; set; } = null!; + + [HasOne] + public RgbColor? Color { get; set; } + + [HasOne] + public TodoItem? TodoItem { get; set; } +} diff --git a/src/Examples/DapperExample/Models/TodoItem.cs b/src/Examples/DapperExample/Models/TodoItem.cs new file mode 100644 index 0000000000..d2f3916268 --- /dev/null +++ b/src/Examples/DapperExample/Models/TodoItem.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class TodoItem : Identifiable +{ + [Attr] + public string Description { get; set; } = null!; + + [Attr] + [Required] + public TodoItemPriority? Priority { get; set; } + + [Attr] + public long? DurationInHours { get; set; } + + [Attr(Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort | AttrCapabilities.AllowView)] + public DateTimeOffset CreatedAt { get; set; } + + [Attr(PublicName = "modifiedAt", Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort | AttrCapabilities.AllowView)] + public DateTimeOffset? LastModifiedAt { get; set; } + + [HasOne] + public Person Owner { get; set; } = null!; + + [HasOne] + public Person? Assignee { get; set; } + + [HasMany] + public ISet Tags { get; set; } = new HashSet(); +} diff --git a/src/Examples/DapperExample/Models/TodoItemPriority.cs b/src/Examples/DapperExample/Models/TodoItemPriority.cs new file mode 100644 index 0000000000..ba10336ec3 --- /dev/null +++ b/src/Examples/DapperExample/Models/TodoItemPriority.cs @@ -0,0 +1,11 @@ +using JetBrains.Annotations; + +namespace DapperExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public enum TodoItemPriority +{ + High = 1, + Medium = 2, + Low = 3 +} diff --git a/src/Examples/DapperExample/Program.cs b/src/Examples/DapperExample/Program.cs new file mode 100644 index 0000000000..6e84497d02 --- /dev/null +++ b/src/Examples/DapperExample/Program.cs @@ -0,0 +1,118 @@ +using System.Diagnostics; +using System.Text.Json.Serialization; +using DapperExample; +using DapperExample.AtomicOperations; +using DapperExample.Data; +using DapperExample.Models; +using DapperExample.Repositories; +using DapperExample.TranslationToSql.DataModel; +using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.AddSingleton(); + +DatabaseProvider databaseProvider = GetDatabaseProvider(builder.Configuration); +string? connectionString = builder.Configuration.GetConnectionString($"DapperExample{databaseProvider}"); + +switch (databaseProvider) +{ + case DatabaseProvider.PostgreSql: + { + builder.Services.AddNpgsql(connectionString, optionsAction: options => SetDbContextDebugOptions(options)); + break; + } + case DatabaseProvider.MySql: + { +#if NET8_0 + ServerVersion serverVersion = ServerVersion.AutoDetect(connectionString); +#else + ServerVersion serverVersion = await ServerVersion.AutoDetectAsync(connectionString); +#endif + + builder.Services.AddMySql(connectionString, serverVersion, optionsAction: options => SetDbContextDebugOptions(options)); + + break; + } + case DatabaseProvider.SqlServer: + { + builder.Services.AddSqlServer(connectionString, optionsAction: options => SetDbContextDebugOptions(options)); + break; + } +} + +builder.Services.AddScoped(typeof(IResourceRepository<,>), typeof(DapperRepository<,>)); +builder.Services.AddScoped(typeof(IResourceWriteRepository<,>), typeof(DapperRepository<,>)); +builder.Services.AddScoped(typeof(IResourceReadRepository<,>), typeof(DapperRepository<,>)); + +builder.Services.AddJsonApi(options => +{ + options.UseRelativeLinks = true; + options.IncludeTotalResourceCount = true; + options.DefaultPageSize = null; + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); + +#if DEBUG + options.IncludeExceptionStackTraceInErrors = true; + options.IncludeRequestBodyInErrors = true; + options.SerializerOptions.WriteIndented = true; +#endif +}, discovery => discovery.AddCurrentAssembly(), resourceGraphBuilder => +{ + resourceGraphBuilder.Add(); + resourceGraphBuilder.Add(); + resourceGraphBuilder.Add(); + resourceGraphBuilder.Add(); + resourceGraphBuilder.Add(); + resourceGraphBuilder.Add(); +}); + +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); +builder.Services.AddScoped(); +builder.Services.AddScoped(serviceProvider => serviceProvider.GetRequiredService()); +builder.Services.AddScoped(); + +WebApplication app = builder.Build(); + +// Configure the HTTP request pipeline. + +app.UseRouting(); +app.UseJsonApi(); +app.MapControllers(); + +await CreateDatabaseAsync(app.Services); + +await app.RunAsync(); + +static DatabaseProvider GetDatabaseProvider(IConfiguration configuration) +{ + return configuration.GetValue("DatabaseProvider"); +} + +[Conditional("DEBUG")] +static void SetDbContextDebugOptions(DbContextOptionsBuilder options) +{ + options.EnableDetailedErrors(); + options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(builder => builder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); +} + +static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) +{ + await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); + + var dbContext = scope.ServiceProvider.GetRequiredService(); + + if (await dbContext.Database.EnsureCreatedAsync()) + { + await Seeder.CreateSampleDataAsync(dbContext); + } +} diff --git a/src/Examples/DapperExample/Properties/AssemblyInfo.cs b/src/Examples/DapperExample/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..acbcc24f88 --- /dev/null +++ b/src/Examples/DapperExample/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("DapperTests")] diff --git a/src/Examples/DapperExample/Properties/launchSettings.json b/src/Examples/DapperExample/Properties/launchSettings.json new file mode 100644 index 0000000000..0d86e8f61a --- /dev/null +++ b/src/Examples/DapperExample/Properties/launchSettings.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:14146", + "sslPort": 44346 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "todoItems?include=owner,assignee,tags&filter=equals(priority,'High')&fields[todoItems]=description,durationInHours", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Kestrel": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "todoItems?include=owner,assignee,tags&filter=equals(priority,'High')&fields[todoItems]=description,durationInHours", + "applicationUrl": "https://localhost:44346;http://localhost:14146", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Examples/DapperExample/Repositories/CommandDefinitionExtensions.cs b/src/Examples/DapperExample/Repositories/CommandDefinitionExtensions.cs new file mode 100644 index 0000000000..294e314eba --- /dev/null +++ b/src/Examples/DapperExample/Repositories/CommandDefinitionExtensions.cs @@ -0,0 +1,22 @@ +using System.Data.Common; +using Dapper; +using DapperExample.AtomicOperations; + +namespace DapperExample.Repositories; + +internal static class CommandDefinitionExtensions +{ + // SQL Server and MySQL require any active DbTransaction to be explicitly associated to the DbConnection. + + public static CommandDefinition Associate(this CommandDefinition command, DbTransaction transaction) + { + return new CommandDefinition(command.CommandText, command.Parameters, transaction, cancellationToken: command.CancellationToken); + } + + public static CommandDefinition Associate(this CommandDefinition command, AmbientTransaction? transaction) + { + return transaction != null + ? new CommandDefinition(command.CommandText, command.Parameters, transaction.Current, cancellationToken: command.CancellationToken) + : command; + } +} diff --git a/src/Examples/DapperExample/Repositories/DapperFacade.cs b/src/Examples/DapperExample/Repositories/DapperFacade.cs new file mode 100644 index 0000000000..e7190ec0de --- /dev/null +++ b/src/Examples/DapperExample/Repositories/DapperFacade.cs @@ -0,0 +1,192 @@ +using Dapper; +using DapperExample.TranslationToSql.Builders; +using DapperExample.TranslationToSql.DataModel; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.Repositories; + +/// +/// Constructs Dapper s from SQL trees and handles order of updates. +/// +internal sealed class DapperFacade +{ + private readonly IDataModelService _dataModelService; + + public DapperFacade(IDataModelService dataModelService) + { + ArgumentNullException.ThrowIfNull(dataModelService); + + _dataModelService = dataModelService; + } + + public CommandDefinition GetSqlCommand(SqlTreeNode node, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(node); + + var queryBuilder = new SqlQueryBuilder(_dataModelService.DatabaseProvider); + string statement = queryBuilder.GetCommand(node); + IDictionary parameters = queryBuilder.Parameters; + + return new CommandDefinition(statement, parameters, cancellationToken: cancellationToken); + } + + public IReadOnlyCollection BuildSqlCommandsForOneToOneRelationshipsChangedToNotNull(ResourceChangeDetector changeDetector, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(changeDetector); + + List sqlCommands = []; + + foreach ((HasOneAttribute relationship, (object? currentRightId, object newRightId)) in changeDetector.GetOneToOneRelationshipsChangedToNotNull()) + { + // To prevent a unique constraint violation on the foreign key, first detach/delete the other row pointing to us, if any. + // See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/502. + + RelationshipForeignKey foreignKey = _dataModelService.GetForeignKey(relationship); + + ResourceType resourceType = foreignKey.IsAtLeftSide ? relationship.LeftType : relationship.RightType; + string whereColumnName = foreignKey.IsAtLeftSide ? foreignKey.ColumnName : TableSourceNode.IdColumnName; + object? whereValue = foreignKey.IsAtLeftSide ? newRightId : currentRightId; + + if (whereValue == null) + { + // Creating new resource, so there can't be any existing FKs in other resources that are already pointing to us. + continue; + } + + if (foreignKey.IsNullable) + { + var updateBuilder = new UpdateClearOneToOneStatementBuilder(_dataModelService); + UpdateNode updateNode = updateBuilder.Build(resourceType, foreignKey.ColumnName, whereColumnName, whereValue); + CommandDefinition sqlCommand = GetSqlCommand(updateNode, cancellationToken); + sqlCommands.Add(sqlCommand); + } + else + { + var deleteBuilder = new DeleteOneToOneStatementBuilder(_dataModelService); + DeleteNode deleteNode = deleteBuilder.Build(resourceType, whereColumnName, whereValue); + CommandDefinition sqlCommand = GetSqlCommand(deleteNode, cancellationToken); + sqlCommands.Add(sqlCommand); + } + } + + return sqlCommands.AsReadOnly(); + } + + public IReadOnlyCollection BuildSqlCommandsForChangedRelationshipsHavingForeignKeyAtRightSide(ResourceChangeDetector changeDetector, + TId leftId, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(changeDetector); + + List sqlCommands = []; + + foreach ((HasOneAttribute hasOneRelationship, (object? currentRightId, object? newRightId)) in changeDetector + .GetChangedToOneRelationshipsWithForeignKeyAtRightSide()) + { + RelationshipForeignKey foreignKey = _dataModelService.GetForeignKey(hasOneRelationship); + + var columnsToUpdate = new Dictionary + { + [foreignKey.ColumnName] = newRightId == null ? null : leftId + }; + + var updateBuilder = new UpdateResourceStatementBuilder(_dataModelService); + UpdateNode updateNode = updateBuilder.Build(hasOneRelationship.RightType, columnsToUpdate, (newRightId ?? currentRightId)!); + CommandDefinition sqlCommand = GetSqlCommand(updateNode, cancellationToken); + sqlCommands.Add(sqlCommand); + } + + foreach ((HasManyAttribute hasManyRelationship, (ISet currentRightIds, ISet newRightIds)) in changeDetector + .GetChangedToManyRelationships()) + { + RelationshipForeignKey foreignKey = _dataModelService.GetForeignKey(hasManyRelationship); + + object[] rightIdsToRemove = currentRightIds.Except(newRightIds).ToArray(); + object[] rightIdsToAdd = newRightIds.Except(currentRightIds).ToArray(); + + if (rightIdsToRemove.Length > 0) + { + CommandDefinition sqlCommand = BuildSqlCommandForRemoveFromToMany(foreignKey, rightIdsToRemove, cancellationToken); + sqlCommands.Add(sqlCommand); + } + + if (rightIdsToAdd.Length > 0) + { + CommandDefinition sqlCommand = BuildSqlCommandForAddToToMany(foreignKey, leftId!, rightIdsToAdd, cancellationToken); + sqlCommands.Add(sqlCommand); + } + } + + return sqlCommands.AsReadOnly(); + } + + public CommandDefinition BuildSqlCommandForRemoveFromToMany(RelationshipForeignKey foreignKey, object[] rightResourceIdValues, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(foreignKey); + ArgumentGuard.NotNullNorEmpty(rightResourceIdValues); + + if (!foreignKey.IsNullable) + { + var deleteBuilder = new DeleteResourceStatementBuilder(_dataModelService); + DeleteNode deleteNode = deleteBuilder.Build(foreignKey.Relationship.RightType, rightResourceIdValues); + return GetSqlCommand(deleteNode, cancellationToken); + } + + var columnsToUpdate = new Dictionary + { + [foreignKey.ColumnName] = null + }; + + var updateBuilder = new UpdateResourceStatementBuilder(_dataModelService); + UpdateNode updateNode = updateBuilder.Build(foreignKey.Relationship.RightType, columnsToUpdate, rightResourceIdValues); + return GetSqlCommand(updateNode, cancellationToken); + } + + public CommandDefinition BuildSqlCommandForAddToToMany(RelationshipForeignKey foreignKey, object leftId, object[] rightResourceIdValues, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(foreignKey); + ArgumentNullException.ThrowIfNull(leftId); + ArgumentGuard.NotNullNorEmpty(rightResourceIdValues); + + var columnsToUpdate = new Dictionary + { + [foreignKey.ColumnName] = leftId + }; + + var updateBuilder = new UpdateResourceStatementBuilder(_dataModelService); + UpdateNode updateNode = updateBuilder.Build(foreignKey.Relationship.RightType, columnsToUpdate, rightResourceIdValues); + return GetSqlCommand(updateNode, cancellationToken); + } + + public CommandDefinition BuildSqlCommandForCreate(ResourceChangeDetector changeDetector, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(changeDetector); + + IReadOnlyDictionary columnsToSet = changeDetector.GetChangedColumnValues(); + + var insertBuilder = new InsertStatementBuilder(_dataModelService); + InsertNode insertNode = insertBuilder.Build(changeDetector.ResourceType, columnsToSet); + return GetSqlCommand(insertNode, cancellationToken); + } + + public CommandDefinition? BuildSqlCommandForUpdate(ResourceChangeDetector changeDetector, TId leftId, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(changeDetector); + + IReadOnlyDictionary columnsToUpdate = changeDetector.GetChangedColumnValues(); + + if (columnsToUpdate.Count > 0) + { + var updateBuilder = new UpdateResourceStatementBuilder(_dataModelService); + UpdateNode updateNode = updateBuilder.Build(changeDetector.ResourceType, columnsToUpdate, leftId!); + return GetSqlCommand(updateNode, cancellationToken); + } + + return null; + } +} diff --git a/src/Examples/DapperExample/Repositories/DapperRepository.cs b/src/Examples/DapperExample/Repositories/DapperRepository.cs new file mode 100644 index 0000000000..716db19519 --- /dev/null +++ b/src/Examples/DapperExample/Repositories/DapperRepository.cs @@ -0,0 +1,590 @@ +using System.Data; +using System.Data.Common; +using System.Diagnostics.CodeAnalysis; +using Dapper; +using DapperExample.AtomicOperations; +using DapperExample.TranslationToSql; +using DapperExample.TranslationToSql.Builders; +using DapperExample.TranslationToSql.DataModel; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.Repositories; + +/// +/// A JsonApiDotNetCore resource repository that converts into SQL and uses +/// to execute the SQL and materialize result sets into JSON:API resources. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +/// +/// This implementation has the following limitations: +/// +/// +/// +/// No pagination. Surprisingly, this is insanely complicated and requires non-standard, vendor-specific SQL. +/// +/// +/// +/// +/// No many-to-many relationships. It requires additional information about the database model but should be possible to implement. +/// +/// +/// +/// +/// No resource inheritance. Requires additional information about the database and is complex to implement. +/// +/// +/// +/// +/// No composite primary/foreign keys. It could be implemented, but it's a corner case that few people use. +/// +/// +/// +/// +/// Only parameterless constructors in resource classes. This is because materialization is performed by Dapper, which doesn't support constructors with +/// parameters. +/// +/// +/// +/// +/// Simple change detection in write operations. It includes scalar properties, but relationships go only one level deep. This is sufficient for +/// JSON:API. +/// +/// +/// +/// +/// The database table/column/key name mapping is based on hardcoded conventions. This could be generalized but wasn't done to keep it simple. +/// +/// +/// +/// +/// Cascading deletes are assumed to occur inside the database, which SQL Server does not support very well. This is a lot of work to implement. +/// +/// +/// +/// +/// No [EagerLoad] support. It could be done, but it's rarely used. +/// +/// +/// +/// +/// Untested with self-referencing resources and relationship cycles. +/// +/// +/// +/// +/// No support for . Because no +/// is used, it doesn't apply. +/// +/// +/// +/// +public sealed partial class DapperRepository : IResourceRepository, IRepositorySupportsTransaction + where TResource : class, IIdentifiable +{ + private readonly ITargetedFields _targetedFields; + private readonly IResourceGraph _resourceGraph; + private readonly IResourceFactory _resourceFactory; + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; + private readonly AmbientTransactionFactory _transactionFactory; + private readonly IDataModelService _dataModelService; + private readonly SqlCaptureStore _captureStore; + private readonly ILoggerFactory _loggerFactory; + private readonly ILogger> _logger; + private readonly ParameterFormatter _parameterFormatter = new(); + private readonly DapperFacade _dapperFacade; + + private ResourceType ResourceType => _resourceGraph.GetResourceType(); + + public string? TransactionId => _transactionFactory.AmbientTransaction?.TransactionId; + + public DapperRepository(ITargetedFields targetedFields, IResourceGraph resourceGraph, IResourceFactory resourceFactory, + IResourceDefinitionAccessor resourceDefinitionAccessor, AmbientTransactionFactory transactionFactory, IDataModelService dataModelService, + SqlCaptureStore captureStore, ILoggerFactory loggerFactory) + { + ArgumentNullException.ThrowIfNull(targetedFields); + ArgumentNullException.ThrowIfNull(resourceGraph); + ArgumentNullException.ThrowIfNull(resourceFactory); + ArgumentNullException.ThrowIfNull(resourceDefinitionAccessor); + ArgumentNullException.ThrowIfNull(transactionFactory); + ArgumentNullException.ThrowIfNull(dataModelService); + ArgumentNullException.ThrowIfNull(captureStore); + ArgumentNullException.ThrowIfNull(loggerFactory); + + _targetedFields = targetedFields; + _resourceGraph = resourceGraph; + _resourceFactory = resourceFactory; + _resourceDefinitionAccessor = resourceDefinitionAccessor; + _transactionFactory = transactionFactory; + _dataModelService = dataModelService; + _captureStore = captureStore; + _loggerFactory = loggerFactory; + _logger = loggerFactory.CreateLogger>(); + _dapperFacade = new DapperFacade(dataModelService); + } + + /// + public async Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(queryLayer); + + var mapper = new ResultSetMapper(queryLayer.Include); + + var selectBuilder = new SelectStatementBuilder(_dataModelService, _loggerFactory); + SelectNode selectNode = selectBuilder.Build(queryLayer, SelectShape.Columns); + CommandDefinition sqlCommand = _dapperFacade.GetSqlCommand(selectNode, cancellationToken); + LogSqlCommand(sqlCommand); + + IReadOnlyCollection resources = await ExecuteQueryAsync(async connection => + { + // Reads must occur within the active transaction, when in an atomic:operations request. + sqlCommand = sqlCommand.Associate(_transactionFactory.AmbientTransaction); + + // Unfortunately, there's no CancellationToken support. See https://github.com/DapperLib/Dapper/issues/1181. + _ = await connection.QueryAsync(sqlCommand.CommandText, mapper.ResourceClrTypes, mapper.Map, sqlCommand.Parameters, sqlCommand.Transaction); + + return mapper.GetResources(); + }, cancellationToken); + + return resources; + } + + /// + public async Task CountAsync(FilterExpression? filter, CancellationToken cancellationToken) + { + var queryLayer = new QueryLayer(ResourceType) + { + Filter = filter + }; + + var selectBuilder = new SelectStatementBuilder(_dataModelService, _loggerFactory); + SelectNode selectNode = selectBuilder.Build(queryLayer, SelectShape.Count); + CommandDefinition sqlCommand = _dapperFacade.GetSqlCommand(selectNode, cancellationToken); + LogSqlCommand(sqlCommand); + + return await ExecuteQueryAsync(async connection => await connection.ExecuteScalarAsync(sqlCommand), cancellationToken); + } + + /// + public Task GetForCreateAsync(Type resourceClrType, [DisallowNull] TId id, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resourceClrType); + + var resource = (TResource)_resourceFactory.CreateInstance(resourceClrType); + resource.Id = id; + + return Task.FromResult(resource); + } + + /// + public async Task CreateAsync(TResource resourceFromRequest, TResource resourceForDatabase, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resourceFromRequest); + ArgumentNullException.ThrowIfNull(resourceForDatabase); + + var changeDetector = new ResourceChangeDetector(ResourceType, _dataModelService); + + await ApplyTargetedFieldsAsync(resourceFromRequest, resourceForDatabase, WriteOperationKind.CreateResource, cancellationToken); + + await _resourceDefinitionAccessor.OnWritingAsync(resourceForDatabase, WriteOperationKind.CreateResource, cancellationToken); + + changeDetector.CaptureNewValues(resourceForDatabase); + + IReadOnlyCollection preSqlCommands = + _dapperFacade.BuildSqlCommandsForOneToOneRelationshipsChangedToNotNull(changeDetector, cancellationToken); + + CommandDefinition insertCommand = _dapperFacade.BuildSqlCommandForCreate(changeDetector, cancellationToken); + + await ExecuteInTransactionAsync(async transaction => + { + foreach (CommandDefinition sqlCommand in preSqlCommands) + { + LogSqlCommand(sqlCommand); + int rowsAffected = await transaction.Connection!.ExecuteAsync(sqlCommand.Associate(transaction)); + + if (rowsAffected > 1) + { + throw new DataStoreUpdateException(new DataException("Multiple rows found.")); + } + } + + LogSqlCommand(insertCommand); + resourceForDatabase.Id = (await transaction.Connection!.ExecuteScalarAsync(insertCommand.Associate(transaction)))!; + + IReadOnlyCollection postSqlCommands = + _dapperFacade.BuildSqlCommandsForChangedRelationshipsHavingForeignKeyAtRightSide(changeDetector, resourceForDatabase.Id, cancellationToken); + + foreach (CommandDefinition sqlCommand in postSqlCommands) + { + LogSqlCommand(sqlCommand); + int rowsAffected = await transaction.Connection!.ExecuteAsync(sqlCommand.Associate(transaction)); + + if (rowsAffected == 0) + { + throw new DataStoreUpdateException(new DataException("Row does not exist.")); + } + } + }, cancellationToken); + + await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceForDatabase, WriteOperationKind.CreateResource, cancellationToken); + } + + private async Task ApplyTargetedFieldsAsync(TResource resourceFromRequest, TResource resourceInDatabase, WriteOperationKind writeOperation, + CancellationToken cancellationToken) + { + foreach (RelationshipAttribute relationship in _targetedFields.Relationships) + { + object? rightValue = relationship.GetValue(resourceFromRequest); + object? rightValueEvaluated = await VisitSetRelationshipAsync(resourceInDatabase, relationship, rightValue, writeOperation, cancellationToken); + + relationship.SetValue(resourceInDatabase, rightValueEvaluated); + } + + foreach (AttrAttribute attribute in _targetedFields.Attributes) + { + attribute.SetValue(resourceInDatabase, attribute.GetValue(resourceFromRequest)); + } + } + + private async Task VisitSetRelationshipAsync(TResource leftResource, RelationshipAttribute relationship, object? rightValue, + WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (relationship is HasOneAttribute hasOneRelationship) + { + return await _resourceDefinitionAccessor.OnSetToOneRelationshipAsync(leftResource, hasOneRelationship, (IIdentifiable?)rightValue, writeOperation, + cancellationToken); + } + + if (relationship is HasManyAttribute hasManyRelationship) + { + HashSet rightResourceIds = CollectionConverter.Instance.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); + + await _resourceDefinitionAccessor.OnSetToManyRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, writeOperation, + cancellationToken); + + return CollectionConverter.Instance.CopyToTypedCollection(rightResourceIds, relationship.Property.PropertyType); + } + + return rightValue; + } + + /// + public async Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(queryLayer); + + IReadOnlyCollection resources = await GetAsync(queryLayer, cancellationToken); + return resources.FirstOrDefault(); + } + + /// + public async Task UpdateAsync(TResource resourceFromRequest, TResource resourceFromDatabase, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resourceFromRequest); + ArgumentNullException.ThrowIfNull(resourceFromDatabase); + + var changeDetector = new ResourceChangeDetector(ResourceType, _dataModelService); + changeDetector.CaptureCurrentValues(resourceFromDatabase); + + await ApplyTargetedFieldsAsync(resourceFromRequest, resourceFromDatabase, WriteOperationKind.UpdateResource, cancellationToken); + + await _resourceDefinitionAccessor.OnWritingAsync(resourceFromDatabase, WriteOperationKind.UpdateResource, cancellationToken); + + changeDetector.CaptureNewValues(resourceFromDatabase); + changeDetector.AssertIsNotClearingAnyRequiredToOneRelationships(ResourceType.PublicName); + + IReadOnlyCollection preSqlCommands = + _dapperFacade.BuildSqlCommandsForOneToOneRelationshipsChangedToNotNull(changeDetector, cancellationToken); + + CommandDefinition? updateCommand = _dapperFacade.BuildSqlCommandForUpdate(changeDetector, resourceFromDatabase.Id, cancellationToken); + + IReadOnlyCollection postSqlCommands = + _dapperFacade.BuildSqlCommandsForChangedRelationshipsHavingForeignKeyAtRightSide(changeDetector, resourceFromDatabase.Id, cancellationToken); + + if (preSqlCommands.Count > 0 || updateCommand != null || postSqlCommands.Count > 0) + { + await ExecuteInTransactionAsync(async transaction => + { + foreach (CommandDefinition sqlCommand in preSqlCommands) + { + LogSqlCommand(sqlCommand); + int rowsAffected = await transaction.Connection!.ExecuteAsync(sqlCommand.Associate(transaction)); + + if (rowsAffected > 1) + { + throw new DataStoreUpdateException(new DataException("Multiple rows found.")); + } + } + + if (updateCommand != null) + { + LogSqlCommand(updateCommand.Value); + int rowsAffected = await transaction.Connection!.ExecuteAsync(updateCommand.Value.Associate(transaction)); + + if (rowsAffected != 1) + { + throw new DataStoreUpdateException(new DataException("Row does not exist or multiple rows found.")); + } + } + + foreach (CommandDefinition sqlCommand in postSqlCommands) + { + LogSqlCommand(sqlCommand); + int rowsAffected = await transaction.Connection!.ExecuteAsync(sqlCommand.Associate(transaction)); + + if (rowsAffected == 0) + { + throw new DataStoreUpdateException(new DataException("Row does not exist.")); + } + } + }, cancellationToken); + + await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceFromDatabase, WriteOperationKind.UpdateResource, cancellationToken); + } + } + + /// + public async Task DeleteAsync(TResource? resourceFromDatabase, [DisallowNull] TId id, CancellationToken cancellationToken) + { + TResource placeholderResource = resourceFromDatabase ?? _resourceFactory.CreateInstance(); + placeholderResource.Id = id; + + await _resourceDefinitionAccessor.OnWritingAsync(placeholderResource, WriteOperationKind.DeleteResource, cancellationToken); + + var deleteBuilder = new DeleteResourceStatementBuilder(_dataModelService); + DeleteNode deleteNode = deleteBuilder.Build(ResourceType, placeholderResource.Id); + CommandDefinition sqlCommand = _dapperFacade.GetSqlCommand(deleteNode, cancellationToken); + + await ExecuteInTransactionAsync(async transaction => + { + LogSqlCommand(sqlCommand); + int rowsAffected = await transaction.Connection!.ExecuteAsync(sqlCommand.Associate(transaction)); + + if (rowsAffected != 1) + { + throw new DataStoreUpdateException(new DataException("Row does not exist or multiple rows found.")); + } + }, cancellationToken); + + await _resourceDefinitionAccessor.OnWriteSucceededAsync(placeholderResource, WriteOperationKind.DeleteResource, cancellationToken); + } + + /// + public async Task SetRelationshipAsync(TResource leftResource, object? rightValue, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(leftResource); + + RelationshipAttribute relationship = _targetedFields.Relationships.Single(); + + var changeDetector = new ResourceChangeDetector(ResourceType, _dataModelService); + changeDetector.CaptureCurrentValues(leftResource); + + object? rightValueEvaluated = + await VisitSetRelationshipAsync(leftResource, relationship, rightValue, WriteOperationKind.SetRelationship, cancellationToken); + + relationship.SetValue(leftResource, rightValueEvaluated); + + await _resourceDefinitionAccessor.OnWritingAsync(leftResource, WriteOperationKind.SetRelationship, cancellationToken); + + changeDetector.CaptureNewValues(leftResource); + changeDetector.AssertIsNotClearingAnyRequiredToOneRelationships(ResourceType.PublicName); + + IReadOnlyCollection preSqlCommands = + _dapperFacade.BuildSqlCommandsForOneToOneRelationshipsChangedToNotNull(changeDetector, cancellationToken); + + CommandDefinition? updateCommand = _dapperFacade.BuildSqlCommandForUpdate(changeDetector, leftResource.Id, cancellationToken); + + IReadOnlyCollection postSqlCommands = + _dapperFacade.BuildSqlCommandsForChangedRelationshipsHavingForeignKeyAtRightSide(changeDetector, leftResource.Id, cancellationToken); + + if (preSqlCommands.Count > 0 || updateCommand != null || postSqlCommands.Count > 0) + { + await ExecuteInTransactionAsync(async transaction => + { + foreach (CommandDefinition sqlCommand in preSqlCommands) + { + LogSqlCommand(sqlCommand); + int rowsAffected = await transaction.Connection!.ExecuteAsync(sqlCommand.Associate(transaction)); + + if (rowsAffected > 1) + { + throw new DataStoreUpdateException(new DataException("Multiple rows found.")); + } + } + + if (updateCommand != null) + { + LogSqlCommand(updateCommand.Value); + int rowsAffected = await transaction.Connection!.ExecuteAsync(updateCommand.Value.Associate(transaction)); + + if (rowsAffected != 1) + { + throw new DataStoreUpdateException(new DataException("Row does not exist or multiple rows found.")); + } + } + + foreach (CommandDefinition sqlCommand in postSqlCommands) + { + LogSqlCommand(sqlCommand); + int rowsAffected = await transaction.Connection!.ExecuteAsync(sqlCommand.Associate(transaction)); + + if (rowsAffected == 0) + { + throw new DataStoreUpdateException(new DataException("Row does not exist.")); + } + } + }, cancellationToken); + + await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResource, WriteOperationKind.SetRelationship, cancellationToken); + } + } + + /// + public async Task AddToToManyRelationshipAsync(TResource? leftResource, [DisallowNull] TId leftId, ISet rightResourceIds, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(rightResourceIds); + + var relationship = (HasManyAttribute)_targetedFields.Relationships.Single(); + + TResource leftPlaceholderResource = leftResource ?? _resourceFactory.CreateInstance(); + leftPlaceholderResource.Id = leftId; + + await _resourceDefinitionAccessor.OnAddToRelationshipAsync(leftPlaceholderResource, relationship, rightResourceIds, cancellationToken); + + relationship.SetValue(leftPlaceholderResource, + CollectionConverter.Instance.CopyToTypedCollection(rightResourceIds, relationship.Property.PropertyType)); + + await _resourceDefinitionAccessor.OnWritingAsync(leftPlaceholderResource, WriteOperationKind.AddToRelationship, cancellationToken); + + if (rightResourceIds.Count > 0) + { + RelationshipForeignKey foreignKey = _dataModelService.GetForeignKey(relationship); + object[] rightResourceIdValues = rightResourceIds.Select(resource => resource.GetTypedId()).ToArray(); + + CommandDefinition sqlCommand = + _dapperFacade.BuildSqlCommandForAddToToMany(foreignKey, leftPlaceholderResource.Id!, rightResourceIdValues, cancellationToken); + + await ExecuteInTransactionAsync(async transaction => + { + LogSqlCommand(sqlCommand); + int rowsAffected = await transaction.Connection!.ExecuteAsync(sqlCommand.Associate(transaction)); + + if (rowsAffected != rightResourceIdValues.Length) + { + throw new DataStoreUpdateException(new DataException("Row does not exist or multiple rows found.")); + } + }, cancellationToken); + + await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftPlaceholderResource, WriteOperationKind.AddToRelationship, cancellationToken); + } + } + + /// + public async Task RemoveFromToManyRelationshipAsync(TResource leftResource, ISet rightResourceIds, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(leftResource); + ArgumentNullException.ThrowIfNull(rightResourceIds); + + var relationship = (HasManyAttribute)_targetedFields.Relationships.Single(); + + await _resourceDefinitionAccessor.OnRemoveFromRelationshipAsync(leftResource, relationship, rightResourceIds, cancellationToken); + relationship.SetValue(leftResource, CollectionConverter.Instance.CopyToTypedCollection(rightResourceIds, relationship.Property.PropertyType)); + + await _resourceDefinitionAccessor.OnWritingAsync(leftResource, WriteOperationKind.RemoveFromRelationship, cancellationToken); + + if (rightResourceIds.Count > 0) + { + RelationshipForeignKey foreignKey = _dataModelService.GetForeignKey(relationship); + object[] rightResourceIdValues = rightResourceIds.Select(resource => resource.GetTypedId()).ToArray(); + CommandDefinition sqlCommand = _dapperFacade.BuildSqlCommandForRemoveFromToMany(foreignKey, rightResourceIdValues, cancellationToken); + + await ExecuteInTransactionAsync(async transaction => + { + LogSqlCommand(sqlCommand); + int rowsAffected = await transaction.Connection!.ExecuteAsync(sqlCommand.Associate(transaction)); + + if (rowsAffected != rightResourceIdValues.Length) + { + throw new DataStoreUpdateException(new DataException("Row does not exist or multiple rows found.")); + } + }, cancellationToken); + + await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResource, WriteOperationKind.RemoveFromRelationship, cancellationToken); + } + } + + private void LogSqlCommand(CommandDefinition command) + { + var parameters = (IDictionary?)command.Parameters; + + _captureStore.Add(command.CommandText, parameters); + + if (_logger.IsEnabled(LogLevel.Information)) + { + if (parameters?.Count > 0) + { + string parametersText = string.Join(", ", parameters.Select(parameter => _parameterFormatter.Format(parameter.Key, parameter.Value))); + LogExecuteWithParameters(Environment.NewLine, command.CommandText, parametersText); + } + else + { + LogExecute(Environment.NewLine, command.CommandText); + } + } + } + + private async Task ExecuteQueryAsync(Func> asyncAction, CancellationToken cancellationToken) + { + if (_transactionFactory.AmbientTransaction != null) + { + DbConnection connection = _transactionFactory.AmbientTransaction.Current.Connection!; + return await asyncAction(connection); + } + + await using DbConnection dbConnection = _dataModelService.CreateConnection(); + await dbConnection.OpenAsync(cancellationToken); + + return await asyncAction(dbConnection); + } + + private async Task ExecuteInTransactionAsync(Func asyncAction, CancellationToken cancellationToken) + { + try + { + if (_transactionFactory.AmbientTransaction != null) + { + await asyncAction(_transactionFactory.AmbientTransaction.Current); + } + else + { + await using AmbientTransaction transaction = await _transactionFactory.BeginTransactionAsync(cancellationToken); + await asyncAction(transaction.Current); + + await transaction.CommitAsync(cancellationToken); + } + } + catch (DbException exception) + { + throw new DataStoreUpdateException(exception); + } + } + + [LoggerMessage(Level = LogLevel.Information, SkipEnabledCheck = true, Message = "Executing SQL: {LineBreak}{Query}")] + private partial void LogExecute(string lineBreak, string query); + + [LoggerMessage(Level = LogLevel.Information, SkipEnabledCheck = true, Message = "Executing SQL with parameters: {Parameters}{LineBreak}{Query}")] + private partial void LogExecuteWithParameters(string lineBreak, string query, string parameters); +} diff --git a/src/Examples/DapperExample/Repositories/ResourceChangeDetector.cs b/src/Examples/DapperExample/Repositories/ResourceChangeDetector.cs new file mode 100644 index 0000000000..58f7579254 --- /dev/null +++ b/src/Examples/DapperExample/Repositories/ResourceChangeDetector.cs @@ -0,0 +1,218 @@ +using DapperExample.TranslationToSql.DataModel; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.Repositories; + +/// +/// A simplistic change detector. Detects changes in scalar properties, but relationship changes only one level deep. +/// +internal sealed class ResourceChangeDetector +{ + private readonly IDataModelService _dataModelService; + + private Dictionary _currentColumnValues = []; + private Dictionary _newColumnValues = []; + + private Dictionary> _currentRightResourcesByRelationship = []; + private Dictionary> _newRightResourcesByRelationship = []; + + public ResourceType ResourceType { get; } + + public ResourceChangeDetector(ResourceType resourceType, IDataModelService dataModelService) + { + ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(dataModelService); + + ResourceType = resourceType; + _dataModelService = dataModelService; + } + + public void CaptureCurrentValues(IIdentifiable resource) + { + ArgumentNullException.ThrowIfNull(resource); + AssertSameType(ResourceType, resource); + + _currentColumnValues = CaptureColumnValues(resource); + _currentRightResourcesByRelationship = CaptureRightResourcesByRelationship(resource); + } + + public void CaptureNewValues(IIdentifiable resource) + { + ArgumentNullException.ThrowIfNull(resource); + AssertSameType(ResourceType, resource); + + _newColumnValues = CaptureColumnValues(resource); + _newRightResourcesByRelationship = CaptureRightResourcesByRelationship(resource); + } + + private Dictionary CaptureColumnValues(IIdentifiable resource) + { + Dictionary columnValues = []; + + foreach ((string columnName, ResourceFieldAttribute? _) in _dataModelService.GetColumnMappings(ResourceType)) + { + columnValues[columnName] = _dataModelService.GetColumnValue(ResourceType, resource, columnName); + } + + return columnValues; + } + + private Dictionary> CaptureRightResourcesByRelationship(IIdentifiable resource) + { + Dictionary> relationshipValues = []; + + foreach (RelationshipAttribute relationship in ResourceType.Relationships) + { + object? rightValue = relationship.GetValue(resource); + HashSet rightResources = CollectionConverter.Instance.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); + + relationshipValues[relationship] = rightResources; + } + + return relationshipValues; + } + + public void AssertIsNotClearingAnyRequiredToOneRelationships(string resourceName) + { + foreach ((RelationshipAttribute relationship, ISet newRightResources) in _newRightResourcesByRelationship) + { + if (relationship is HasOneAttribute hasOneRelationship) + { + RelationshipForeignKey foreignKey = _dataModelService.GetForeignKey(hasOneRelationship); + + if (!foreignKey.IsNullable) + { + object? currentRightId = + _currentRightResourcesByRelationship.TryGetValue(hasOneRelationship, out HashSet? currentRightResources) + ? currentRightResources.FirstOrDefault()?.GetTypedId() + : null; + + object? newRightId = newRightResources.SingleOrDefault()?.GetTypedId(); + + bool hasChanged = !Equals(currentRightId, newRightId); + + if (hasChanged && newRightId == null) + { + throw new CannotClearRequiredRelationshipException(relationship.PublicName, resourceName); + } + } + } + } + } + + public IReadOnlyDictionary GetOneToOneRelationshipsChangedToNotNull() + { + Dictionary changes = []; + + foreach ((RelationshipAttribute relationship, ISet newRightResources) in _newRightResourcesByRelationship) + { + if (relationship is HasOneAttribute { IsOneToOne: true } hasOneRelationship) + { + object? newRightId = newRightResources.SingleOrDefault()?.GetTypedId(); + + if (newRightId != null) + { + object? currentRightId = + _currentRightResourcesByRelationship.TryGetValue(hasOneRelationship, out HashSet? currentRightResources) + ? currentRightResources.FirstOrDefault()?.GetTypedId() + : null; + + if (!Equals(currentRightId, newRightId)) + { + changes[hasOneRelationship] = (currentRightId, newRightId); + } + } + } + } + + return changes.AsReadOnly(); + } + + public IReadOnlyDictionary GetChangedColumnValues() + { + Dictionary changes = []; + + foreach ((string columnName, object? newColumnValue) in _newColumnValues) + { + bool currentFound = _currentColumnValues.TryGetValue(columnName, out object? currentColumnValue); + + if (!currentFound || !Equals(currentColumnValue, newColumnValue)) + { + changes[columnName] = newColumnValue; + } + } + + return changes.AsReadOnly(); + } + + public IReadOnlyDictionary GetChangedToOneRelationshipsWithForeignKeyAtRightSide() + { + Dictionary changes = []; + + foreach ((RelationshipAttribute relationship, ISet newRightResources) in _newRightResourcesByRelationship) + { + if (relationship is HasOneAttribute hasOneRelationship) + { + RelationshipForeignKey foreignKey = _dataModelService.GetForeignKey(hasOneRelationship); + + if (foreignKey.IsAtLeftSide) + { + continue; + } + + object? currentRightId = _currentRightResourcesByRelationship.TryGetValue(hasOneRelationship, out HashSet? currentRightResources) + ? currentRightResources.FirstOrDefault()?.GetTypedId() + : null; + + object? newRightId = newRightResources.SingleOrDefault()?.GetTypedId(); + + if (!Equals(currentRightId, newRightId)) + { + changes[hasOneRelationship] = (currentRightId, newRightId); + } + } + } + + return changes.AsReadOnly(); + } + + public IReadOnlyDictionary currentRightIds, ISet newRightIds)> GetChangedToManyRelationships() + { + Dictionary currentRightIds, ISet newRightIds)> changes = []; + + foreach ((RelationshipAttribute relationship, ISet newRightResources) in _newRightResourcesByRelationship) + { + if (relationship is HasManyAttribute hasManyRelationship) + { + HashSet newRightIds = newRightResources.Select(resource => resource.GetTypedId()).ToHashSet(); + + HashSet currentRightIds = + _currentRightResourcesByRelationship.TryGetValue(hasManyRelationship, out HashSet? currentRightResources) + ? currentRightResources.Select(resource => resource.GetTypedId()).ToHashSet() + : []; + + if (!currentRightIds.SetEquals(newRightIds)) + { + changes[hasManyRelationship] = (currentRightIds, newRightIds); + } + } + } + + return changes.AsReadOnly(); + } + + private static void AssertSameType(ResourceType resourceType, IIdentifiable resource) + { + Type declaredType = resourceType.ClrType; + Type instanceType = resource.GetClrType(); + + if (instanceType != declaredType) + { + throw new ArgumentException($"Expected resource of type '{declaredType.Name}' instead of '{instanceType.Name}'.", nameof(resource)); + } + } +} diff --git a/src/Examples/DapperExample/Repositories/ResultSetMapper.cs b/src/Examples/DapperExample/Repositories/ResultSetMapper.cs new file mode 100644 index 0000000000..89a7890fd0 --- /dev/null +++ b/src/Examples/DapperExample/Repositories/ResultSetMapper.cs @@ -0,0 +1,177 @@ +using System.Reflection; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.Repositories; + +/// +/// Maps the result set from a SQL query that includes primary and related resources. +/// +internal sealed class ResultSetMapper + where TResource : class, IIdentifiable +{ + private readonly List _joinObjectTypes = []; + + // For each object type, we keep a map of ID/instance pairs. + // Note we don't do full bidirectional relationship fix-up; this just avoids duplicate instances. + private readonly Dictionary> _resourceByTypeCache = []; + + // Used to determine where in the tree of included relationships a join object belongs to. + private readonly Dictionary _includeElementToJoinObjectArrayIndexLookup = new(ReferenceEqualityComparer.Instance); + + // The return value of the mapping process. + private readonly List _primaryResourcesInOrder = []; + + // The included relationships for which an INNER/LEFT JOIN statement was produced, which we're mapping. + private readonly IncludeExpression _include; + + public Type[] ResourceClrTypes => _joinObjectTypes.ToArray(); + + public ResultSetMapper(IncludeExpression? include) + { + _include = include ?? IncludeExpression.Empty; + _joinObjectTypes.Add(typeof(TResource)); + _resourceByTypeCache[typeof(TResource)] = []; + + var walker = new IncludeElementWalker(_include); + int index = 1; + + foreach (IncludeElementExpression includeElement in walker.BreadthFirstEnumerate()) + { + _joinObjectTypes.Add(includeElement.Relationship.RightType.ClrType); + _resourceByTypeCache[includeElement.Relationship.RightType.ClrType] = []; + _includeElementToJoinObjectArrayIndexLookup[includeElement] = index; + + index++; + } + } + + public object? Map(object[] joinObjects) + { + // This method executes for each row in the SQL result set. + + if (joinObjects.Length != _includeElementToJoinObjectArrayIndexLookup.Count + 1) + { + throw new InvalidOperationException("Failed to properly map SQL result set into objects."); + } + + object?[] objectsCached = joinObjects.Select(GetCached).ToArray(); + var leftResource = (TResource?)objectsCached[0]; + + if (leftResource == null) + { + throw new InvalidOperationException("Failed to properly map SQL result set into objects."); + } + + RecursiveSetRelationships(leftResource, _include.Elements, objectsCached); + + _primaryResourcesInOrder.Add(leftResource); + return null; + } + + private object? GetCached(object? resource) + { + if (resource == null) + { + return null; + } + + object? resourceId = GetResourceId(resource); + + if (resourceId == null || HasDefaultValue(resourceId)) + { + // When Id is not set, the entire object is empty (due to LEFT JOIN usage). + return null; + } + + Dictionary resourceByIdCache = _resourceByTypeCache[resource.GetType()]; + + if (resourceByIdCache.TryGetValue(resourceId, out object? cachedValue)) + { + return cachedValue; + } + + resourceByIdCache[resourceId] = resource; + return resource; + } + + private static object? GetResourceId(object resource) + { + PropertyInfo? property = resource.GetType().GetProperty(TableSourceNode.IdColumnName); + + if (property == null) + { + throw new InvalidOperationException($"{TableSourceNode.IdColumnName} property not found on object of type '{resource.GetType().Name}'."); + } + + return property.GetValue(resource); + } + + private bool HasDefaultValue(object value) + { + object? defaultValue = RuntimeTypeConverter.GetDefaultValue(value.GetType()); + return Equals(defaultValue, value); + } + + private void RecursiveSetRelationships(object leftResource, IEnumerable includeElements, object?[] joinObjects) + { + foreach (IncludeElementExpression includeElement in includeElements) + { + int rightIndex = _includeElementToJoinObjectArrayIndexLookup[includeElement]; + object? rightResource = joinObjects[rightIndex]; + + SetRelationship(leftResource, includeElement.Relationship, rightResource); + + if (rightResource != null && includeElement.Children.Count > 0) + { + RecursiveSetRelationships(rightResource, includeElement.Children, joinObjects); + } + } + } + + private void SetRelationship(object leftResource, RelationshipAttribute relationship, object? rightResource) + { + if (rightResource != null) + { + if (relationship is HasManyAttribute hasManyRelationship) + { + hasManyRelationship.AddValue(leftResource, (IIdentifiable)rightResource); + } + else + { + relationship.SetValue(leftResource, rightResource); + } + } + } + + public IReadOnlyCollection GetResources() + { + return _primaryResourcesInOrder.DistinctBy(resource => resource.Id).ToArray().AsReadOnly(); + } + + private sealed class IncludeElementWalker(IncludeExpression include) + { + private readonly IncludeExpression _include = include; + + public IEnumerable BreadthFirstEnumerate() + { + foreach (IncludeElementExpression next in _include.Elements.OrderBy(element => element.Relationship.PublicName) + .SelectMany(RecursiveEnumerateElement)) + { + yield return next; + } + } + + private IEnumerable RecursiveEnumerateElement(IncludeElementExpression element) + { + yield return element; + + foreach (IncludeElementExpression next in element.Children.OrderBy(child => child.Relationship.PublicName).SelectMany(RecursiveEnumerateElement)) + { + yield return next; + } + } + } +} diff --git a/src/Examples/DapperExample/Repositories/SqlCaptureStore.cs b/src/Examples/DapperExample/Repositories/SqlCaptureStore.cs new file mode 100644 index 0000000000..701f1ca740 --- /dev/null +++ b/src/Examples/DapperExample/Repositories/SqlCaptureStore.cs @@ -0,0 +1,26 @@ +using DapperExample.TranslationToSql; +using JetBrains.Annotations; + +namespace DapperExample.Repositories; + +/// +/// Captures the emitted SQL statements, which enables integration tests to assert on them. +/// +[PublicAPI] +public sealed class SqlCaptureStore +{ + private readonly List _sqlCommands = []; + + public IReadOnlyList SqlCommands => _sqlCommands.AsReadOnly(); + + public void Clear() + { + _sqlCommands.Clear(); + } + + internal void Add(string statement, IDictionary? parameters) + { + var sqlCommand = new SqlCommand(statement, parameters ?? new Dictionary()); + _sqlCommands.Add(sqlCommand); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/DeleteOneToOneStatementBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/DeleteOneToOneStatementBuilder.cs new file mode 100644 index 0000000000..afd955dc62 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Builders/DeleteOneToOneStatementBuilder.cs @@ -0,0 +1,32 @@ +using DapperExample.TranslationToSql.DataModel; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; + +namespace DapperExample.TranslationToSql.Builders; + +internal sealed class DeleteOneToOneStatementBuilder(IDataModelService dataModelService) + : StatementBuilder(dataModelService) +{ + public DeleteNode Build(ResourceType resourceType, string whereColumnName, object? whereValue) + { + ArgumentNullException.ThrowIfNull(resourceType); + ArgumentException.ThrowIfNullOrEmpty(whereColumnName); + + ResetState(); + + TableNode table = GetTable(resourceType, null); + + ColumnNode column = table.GetColumn(whereColumnName, null, table.Alias); + WhereNode where = GetWhere(column, whereValue); + + return new DeleteNode(table, where); + } + + private WhereNode GetWhere(ColumnNode column, object? value) + { + ParameterNode parameter = ParameterGenerator.Create(value); + var filter = new ComparisonNode(ComparisonOperator.Equals, column, parameter); + return new WhereNode(filter); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/DeleteResourceStatementBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/DeleteResourceStatementBuilder.cs new file mode 100644 index 0000000000..b3afe4a2a4 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Builders/DeleteResourceStatementBuilder.cs @@ -0,0 +1,34 @@ +using System.Collections.ObjectModel; +using DapperExample.TranslationToSql.DataModel; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; + +namespace DapperExample.TranslationToSql.Builders; + +internal sealed class DeleteResourceStatementBuilder(IDataModelService dataModelService) + : StatementBuilder(dataModelService) +{ + public DeleteNode Build(ResourceType resourceType, params object[] idValues) + { + ArgumentNullException.ThrowIfNull(resourceType); + ArgumentGuard.NotNullNorEmpty(idValues); + + ResetState(); + + TableNode table = GetTable(resourceType, null); + + ColumnNode idColumn = table.GetIdColumn(table.Alias); + WhereNode where = GetWhere(idColumn, idValues); + + return new DeleteNode(table, where); + } + + private WhereNode GetWhere(ColumnNode idColumn, IEnumerable idValues) + { + ReadOnlyCollection parameters = idValues.Select(ParameterGenerator.Create).ToArray().AsReadOnly(); + FilterNode filter = parameters.Count == 1 ? new ComparisonNode(ComparisonOperator.Equals, idColumn, parameters[0]) : new InNode(idColumn, parameters); + return new WhereNode(filter); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/InsertStatementBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/InsertStatementBuilder.cs new file mode 100644 index 0000000000..2f71cee112 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Builders/InsertStatementBuilder.cs @@ -0,0 +1,51 @@ +using System.Collections.ObjectModel; +using DapperExample.TranslationToSql.DataModel; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; + +namespace DapperExample.TranslationToSql.Builders; + +internal sealed class InsertStatementBuilder(IDataModelService dataModelService) + : StatementBuilder(dataModelService) +{ + public InsertNode Build(ResourceType resourceType, IReadOnlyDictionary columnsToSet) + { + ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(columnsToSet); + + ResetState(); + + TableNode table = GetTable(resourceType, null); + ReadOnlyCollection assignments = GetColumnAssignments(columnsToSet, table); + + return new InsertNode(table, assignments); + } + + private ReadOnlyCollection GetColumnAssignments(IReadOnlyDictionary columnsToSet, TableNode table) + { + List assignments = []; + ColumnNode idColumn = table.GetIdColumn(table.Alias); + + foreach ((string columnName, object? columnValue) in columnsToSet) + { + if (columnName == idColumn.Name) + { + object? defaultIdValue = columnValue == null ? null : RuntimeTypeConverter.GetDefaultValue(columnValue.GetType()); + + if (Equals(columnValue, defaultIdValue)) + { + continue; + } + } + + ColumnNode column = table.GetColumn(columnName, null, table.Alias); + ParameterNode parameter = ParameterGenerator.Create(columnValue); + + var assignment = new ColumnAssignmentNode(column, parameter); + assignments.Add(assignment); + } + + return assignments.AsReadOnly(); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/SelectShape.cs b/src/Examples/DapperExample/TranslationToSql/Builders/SelectShape.cs new file mode 100644 index 0000000000..d4fdd09b69 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Builders/SelectShape.cs @@ -0,0 +1,22 @@ +namespace DapperExample.TranslationToSql.Builders; + +/// +/// Indicates what to select in a SELECT statement. +/// +internal enum SelectShape +{ + /// + /// Select a set of columns. + /// + Columns, + + /// + /// Select the number of rows: COUNT(*). + /// + Count, + + /// + /// Select only the first, unnamed column: SELECT 1. + /// + One +} diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/SelectStatementBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/SelectStatementBuilder.cs new file mode 100644 index 0000000000..23a2b7d2d0 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Builders/SelectStatementBuilder.cs @@ -0,0 +1,780 @@ +using System.Collections.ObjectModel; +using System.Net; +using DapperExample.TranslationToSql.DataModel; +using DapperExample.TranslationToSql.Generators; +using DapperExample.TranslationToSql.Transformations; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.QueryableBuilding; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +namespace DapperExample.TranslationToSql.Builders; + +/// +/// Builds a SELECT statement from a . +/// +internal sealed class SelectStatementBuilder : QueryExpressionVisitor +{ + // State that is shared between sub-queries. + private readonly QueryState _queryState; + + // The FROM/JOIN/sub-SELECT tables, along with their selectors (which usually are column references). + private readonly Dictionary> _selectorsPerTable = []; + + // Used to assign unique names when adding selectors, in case tables are joined that would result in duplicate column names. + private readonly HashSet _selectorNamesUsed = []; + + // Filter constraints. + private readonly List _whereFilters = []; + + // Sorting on columns, or COUNT(*) in a sub-query. + private readonly List _orderByTerms = []; + + // Indicates whether to select a set of columns, the number of rows, or only the first (unnamed) column. + private SelectShape _selectShape; + + public SelectStatementBuilder(IDataModelService dataModelService, ILoggerFactory loggerFactory) + : this(new QueryState(dataModelService, new TableAliasGenerator(), new ParameterGenerator(), loggerFactory)) + { + } + + private SelectStatementBuilder(QueryState queryState) + { + _queryState = queryState; + } + + public SelectNode Build(QueryLayer queryLayer, SelectShape selectShape) + { + ArgumentNullException.ThrowIfNull(queryLayer); + + // Convert queryLayer.Include into multiple levels of queryLayer.Selection. + var includeConverter = new QueryLayerIncludeConverter(queryLayer); + includeConverter.ConvertIncludesToSelections(); + + ResetState(selectShape); + + FromNode primaryTableAccessor = CreatePrimaryTable(queryLayer.ResourceType); + ConvertQueryLayer(queryLayer, primaryTableAccessor); + + SelectNode select = ToSelect(false, false); + + if (_selectShape == SelectShape.Columns) + { + var staleRewriter = new StaleColumnReferenceRewriter(_queryState.OldToNewTableAliasMap, _queryState.LoggerFactory); + select = staleRewriter.PullColumnsIntoScope(select); + + var selectorsRewriter = new UnusedSelectorsRewriter(_queryState.LoggerFactory); + select = selectorsRewriter.RemoveUnusedSelectorsInSubQueries(select); + } + + return select; + } + + private void ResetState(SelectShape selectShape) + { + _queryState.Reset(); + _selectorsPerTable.Clear(); + _selectorNamesUsed.Clear(); + _whereFilters.Clear(); + _orderByTerms.Clear(); + _selectShape = selectShape; + } + + private FromNode CreatePrimaryTable(ResourceType resourceType) + { + IReadOnlyDictionary columnMappings = _queryState.DataModelService.GetColumnMappings(resourceType); + var table = new TableNode(resourceType, columnMappings, _queryState.TableAliasGenerator.GetNext()); + var from = new FromNode(table); + + TrackPrimaryTable(from); + return from; + } + + private void TrackPrimaryTable(TableAccessorNode tableAccessor) + { + if (_selectorsPerTable.Count > 0) + { + throw new InvalidOperationException("A primary table already exists."); + } + + _queryState.RelatedTables.Add(tableAccessor, []); + + _selectorsPerTable[tableAccessor] = _selectShape switch + { + SelectShape.Columns => Array.Empty(), + SelectShape.Count => [new CountSelectorNode(null)], + _ => [new OneSelectorNode(null)] + }; + } + + private void ConvertQueryLayer(QueryLayer queryLayer, TableAccessorNode tableAccessor) + { + if (queryLayer.Filter != null) + { + var filter = (FilterNode)Visit(queryLayer.Filter, tableAccessor); + _whereFilters.Add(filter); + } + + if (queryLayer.Sort != null) + { + var orderBy = (OrderByNode)Visit(queryLayer.Sort, tableAccessor); + _orderByTerms.AddRange(orderBy.Terms); + } + + if (queryLayer.Pagination is { PageSize: not null }) + { + throw new NotSupportedException("Pagination is not supported."); + } + + if (queryLayer.Selection != null) + { + foreach (ResourceType resourceType in queryLayer.Selection.GetResourceTypes()) + { + FieldSelectors selectors = queryLayer.Selection.GetOrCreateSelectors(resourceType); + ConvertFieldSelectors(selectors, tableAccessor); + } + } + } + + private void ConvertFieldSelectors(FieldSelectors selectors, TableAccessorNode tableAccessor) + { + HashSet selectedColumns = []; + Dictionary nextLayers = []; + + if (selectors.IsEmpty || selectors.ContainsReadOnlyAttribute || selectors.ContainsOnlyRelationships) + { + // If a read-only attribute is selected, its calculated value likely depends on another property, so fetch all scalar properties. + // And only selecting relationships implicitly means to fetch all scalar properties as well. + // Additionally, empty selectors (originating from eliminated includes) indicate to fetch all scalar properties too. + + selectedColumns = tableAccessor.Source.Columns.Where(column => column.Type == ColumnType.Scalar).ToHashSet(); + } + + foreach ((ResourceFieldAttribute field, QueryLayer? nextLayer) in selectors.OrderBy(selector => selector.Key.PublicName)) + { + if (field is AttrAttribute attribute) + { + // Returns null when the set contains an unmapped column, which is silently ignored. + ColumnNode? column = tableAccessor.Source.FindColumn(attribute.Property.Name, ColumnType.Scalar, tableAccessor.Source.Alias); + + if (column != null) + { + selectedColumns.Add(column); + } + } + + if (field is RelationshipAttribute relationship && nextLayer != null) + { + nextLayers.Add(relationship, nextLayer); + } + } + + if (_selectShape == SelectShape.Columns) + { + SetColumnSelectors(tableAccessor, selectedColumns); + } + + foreach ((RelationshipAttribute relationship, QueryLayer nextLayer) in nextLayers) + { + ConvertNestedQueryLayer(tableAccessor, relationship, nextLayer); + } + } + + private void SetColumnSelectors(TableAccessorNode tableAccessor, IEnumerable columns) + { + if (!_selectorsPerTable.ContainsKey(tableAccessor)) + { + throw new InvalidOperationException($"Table {tableAccessor.Source.Alias} not found in selected tables."); + } + + // When selecting from a table, use a deterministic order to simplify test assertions. + // When selecting from a sub-query (typically spanning multiple tables and renamed columns), existing order must be preserved. + _selectorsPerTable[tableAccessor] = tableAccessor.Source is SelectNode + ? PreserveColumnOrderEnsuringUniqueNames(columns).AsReadOnly() + : OrderColumnsWithIdAtFrontEnsuringUniqueNames(columns).AsReadOnly(); + } + + private List PreserveColumnOrderEnsuringUniqueNames(IEnumerable columns) + { + List selectors = []; + + foreach (ColumnNode column in columns) + { + string uniqueName = GetUniqueSelectorName(column.Name); + string? selectorAlias = uniqueName != column.Name ? uniqueName : null; + var columnSelector = new ColumnSelectorNode(column, selectorAlias); + selectors.Add(columnSelector); + } + + return selectors; + } + + private SelectorNode[] OrderColumnsWithIdAtFrontEnsuringUniqueNames(IEnumerable columns) + { + Dictionary> selectorsPerTable = []; + + foreach (ColumnNode column in columns.OrderBy(column => column.GetTableAliasIndex()).ThenBy(column => column.Name)) + { + string tableAlias = column.TableAlias ?? "!"; + selectorsPerTable.TryAdd(tableAlias, []); + + string uniqueName = GetUniqueSelectorName(column.Name); + string? selectorAlias = uniqueName != column.Name ? uniqueName : null; + var columnSelector = new ColumnSelectorNode(column, selectorAlias); + + if (column.Name == TableSourceNode.IdColumnName) + { + selectorsPerTable[tableAlias].Insert(0, columnSelector); + } + else + { + selectorsPerTable[tableAlias].Add(columnSelector); + } + } + + return selectorsPerTable.SelectMany(selector => selector.Value).ToArray(); + } + + private string GetUniqueSelectorName(string columnName) + { + string uniqueName = columnName; + + while (_selectorNamesUsed.Contains(uniqueName)) + { + uniqueName += "0"; + } + + _selectorNamesUsed.Add(uniqueName); + return uniqueName; + } + + private void ConvertNestedQueryLayer(TableAccessorNode tableAccessor, RelationshipAttribute relationship, QueryLayer nextLayer) + { + bool requireSubQuery = nextLayer.Filter != null; + + if (requireSubQuery) + { + var subSelectBuilder = new SelectStatementBuilder(_queryState); + + FromNode primaryTableAccessor = subSelectBuilder.CreatePrimaryTable(relationship.RightType); + subSelectBuilder.ConvertQueryLayer(nextLayer, primaryTableAccessor); + + string[] innerTableAliases = subSelectBuilder._selectorsPerTable.Keys.Select(accessor => accessor.Source.Alias).Cast().ToArray(); + + // In the sub-query, select all columns, to enable referencing them from other locations in the query. + // This usually produces unused selectors, which will be removed in a post-processing step. + var selectorsToKeep = new Dictionary>(subSelectBuilder._selectorsPerTable); + subSelectBuilder.SelectAllColumnsInAllTables(selectorsToKeep.Keys); + + // Since there's no pagination support, it's pointless to preserve orderings in the sub-query. + OrderByTermNode[] orderingsToKeep = subSelectBuilder._orderByTerms.ToArray(); + subSelectBuilder._orderByTerms.Clear(); + + SelectNode aliasedSubQuery = subSelectBuilder.ToSelect(true, true); + + // Store inner-to-outer table aliases, to enable rewriting stale column references in a post-processing step. + // This is required for orderings that contain sub-selects, resulting from order-by-count. + MapOldTableAliasesToSubQuery(innerTableAliases, aliasedSubQuery.Alias!); + + TableAccessorNode outerTableAccessor = CreateRelatedTable(tableAccessor, relationship, aliasedSubQuery); + + // In the outer query, select only what was originally selected. + _selectorsPerTable[outerTableAccessor] = + MapSelectorsFromSubQuery(selectorsToKeep.SelectMany(selector => selector.Value), aliasedSubQuery).AsReadOnly(); + + // To achieve total ordering, all orderings from sub-query must always appear in the root query. + List outerOrderingsToAdd = MapOrderingsFromSubQuery(orderingsToKeep, aliasedSubQuery); + _orderByTerms.AddRange(outerOrderingsToAdd); + } + else + { + TableAccessorNode relatedTableAccessor = GetOrCreateRelatedTable(tableAccessor, relationship); + ConvertQueryLayer(nextLayer, relatedTableAccessor); + } + } + + private void SelectAllColumnsInAllTables(IEnumerable tableAccessors) + { + _selectorsPerTable.Clear(); + _selectorNamesUsed.Clear(); + + foreach (TableAccessorNode tableAccessor in tableAccessors) + { + _selectorsPerTable.Add(tableAccessor, Array.Empty()); + + if (_selectShape == SelectShape.Columns) + { + SetColumnSelectors(tableAccessor, tableAccessor.Source.Columns); + } + } + } + + private void MapOldTableAliasesToSubQuery(IEnumerable oldTableAliases, string newTableAlias) + { + foreach (string oldTableAlias in oldTableAliases) + { + _queryState.OldToNewTableAliasMap[oldTableAlias] = newTableAlias; + } + } + + private TableAccessorNode CreateRelatedTable(TableAccessorNode leftTableAccessor, RelationshipAttribute relationship, TableSourceNode rightTableSource) + { + RelationshipForeignKey foreignKey = _queryState.DataModelService.GetForeignKey(relationship); + JoinType joinType = foreignKey is { IsAtLeftSide: true, IsNullable: false } ? JoinType.InnerJoin : JoinType.LeftJoin; + + ComparisonNode joinCondition = CreateJoinCondition(leftTableAccessor.Source, relationship, rightTableSource); + + TableAccessorNode relatedTableAccessor = new JoinNode(joinType, rightTableSource, (ColumnNode)joinCondition.Left, (ColumnNode)joinCondition.Right); + + TrackRelatedTable(leftTableAccessor, relationship, relatedTableAccessor); + return relatedTableAccessor; + } + + private ComparisonNode CreateJoinCondition(TableSourceNode outerTableSource, RelationshipAttribute relationship, TableSourceNode innerTableSource) + { + RelationshipForeignKey foreignKey = _queryState.DataModelService.GetForeignKey(relationship); + + ColumnNode innerColumn = foreignKey.IsAtLeftSide + ? innerTableSource.GetIdColumn(innerTableSource.Alias) + : innerTableSource.GetColumn(foreignKey.ColumnName, ColumnType.ForeignKey, innerTableSource.Alias); + + ColumnNode outerColumn = foreignKey.IsAtLeftSide + ? outerTableSource.GetColumn(foreignKey.ColumnName, ColumnType.ForeignKey, outerTableSource.Alias) + : outerTableSource.GetIdColumn(outerTableSource.Alias); + + return new ComparisonNode(ComparisonOperator.Equals, outerColumn, innerColumn); + } + + private void TrackRelatedTable(TableAccessorNode leftTableAccessor, RelationshipAttribute relationship, TableAccessorNode rightTableAccessor) + { + _queryState.RelatedTables.Add(rightTableAccessor, []); + _selectorsPerTable[rightTableAccessor] = Array.Empty(); + + _queryState.RelatedTables[leftTableAccessor].Add(relationship, rightTableAccessor); + } + + private List MapSelectorsFromSubQuery(IEnumerable innerSelectorsToKeep, SelectNode select) + { + List outerColumnsToKeep = []; + + foreach (SelectorNode innerSelector in innerSelectorsToKeep) + { + if (innerSelector is ColumnSelectorNode innerColumnSelector) + { + // t2."Id" AS Id0 => t3.Id0 + ColumnNode innerColumn = innerColumnSelector.Column; + ColumnNode outerColumn = select.Columns.Single(outerColumn => outerColumn.Selector.Column == innerColumn); + outerColumnsToKeep.Add(outerColumn); + } + else + { + // If there's an alias, we should use it. Otherwise we could fallback to ordinal selector. + throw new NotImplementedException("Mapping non-column selectors is not implemented."); + } + } + + return PreserveColumnOrderEnsuringUniqueNames(outerColumnsToKeep); + } + + private List MapOrderingsFromSubQuery(IEnumerable innerOrderingsToKeep, SelectNode select) + { + List orderingsToKeep = []; + + foreach (OrderByTermNode innerTerm in innerOrderingsToKeep) + { + if (innerTerm is OrderByColumnNode orderByColumn) + { + ColumnNode outerColumn = select.Columns.Single(outerColumn => outerColumn.Selector.Column == orderByColumn.Column); + var outerTerm = new OrderByColumnNode(outerColumn, innerTerm.IsAscending); + orderingsToKeep.Add(outerTerm); + } + else + { + // Rewriting stale column references from order-by-count is non-trivial, so let the post-processor handle them. + orderingsToKeep.Add(innerTerm); + } + } + + return orderingsToKeep; + } + + private TableAccessorNode GetOrCreateRelatedTable(TableAccessorNode leftTableAccessor, RelationshipAttribute relationship) + { + TableAccessorNode? relatedTableAccessor = _selectorsPerTable.Count == 0 + // Joining against something in an outer query. + ? CreatePrimaryTableWithIdentityCondition(leftTableAccessor.Source, relationship) + : FindRelatedTable(leftTableAccessor, relationship); + + if (relatedTableAccessor == null) + { + IReadOnlyDictionary columnMappings = _queryState.DataModelService.GetColumnMappings(relationship.RightType); + var rightTable = new TableNode(relationship.RightType, columnMappings, _queryState.TableAliasGenerator.GetNext()); + + return CreateRelatedTable(leftTableAccessor, relationship, rightTable); + } + + return relatedTableAccessor; + } + + private FromNode CreatePrimaryTableWithIdentityCondition(TableSourceNode outerTableSource, RelationshipAttribute relationship) + { + FromNode innerTableAccessor = CreatePrimaryTable(relationship.RightType); + + ComparisonNode joinCondition = CreateJoinCondition(outerTableSource, relationship, innerTableAccessor.Source); + _whereFilters.Add(joinCondition); + + return innerTableAccessor; + } + + private TableAccessorNode? FindRelatedTable(TableAccessorNode leftTableAccessor, RelationshipAttribute relationship) + { + Dictionary rightTableAccessors = _queryState.RelatedTables[leftTableAccessor]; + return rightTableAccessors.GetValueOrDefault(relationship); + } + + private SelectNode ToSelect(bool isSubQuery, bool createAlias) + { + WhereNode? where = GetWhere(); + OrderByNode? orderBy = _orderByTerms.Count == 0 ? null : new OrderByNode(_orderByTerms.AsReadOnly()); + + // Materialization using Dapper requires selectors to match property names, so adjust selector names accordingly. + Dictionary> selectorsPerTable = + isSubQuery ? _selectorsPerTable : AliasSelectorsToTableColumnNames(_selectorsPerTable); + + string? alias = createAlias ? _queryState.TableAliasGenerator.GetNext() : null; + return new SelectNode(selectorsPerTable.AsReadOnly(), where, orderBy, alias); + } + + private WhereNode? GetWhere() + { + if (_whereFilters.Count == 0) + { + return null; + } + + var combinator = new LogicalCombinator(); + + FilterNode filter = _whereFilters.Count == 1 ? _whereFilters[0] : new LogicalNode(LogicalOperator.And, _whereFilters.AsReadOnly()); + FilterNode collapsed = combinator.Collapse(filter); + + return new WhereNode(collapsed); + } + + private static Dictionary> AliasSelectorsToTableColumnNames( + Dictionary> selectorsPerTable) + { + Dictionary> aliasedSelectors = []; + + foreach ((TableAccessorNode tableAccessor, IReadOnlyList tableSelectors) in selectorsPerTable) + { + aliasedSelectors[tableAccessor] = tableSelectors.Select(AliasToTableColumnName).ToArray().AsReadOnly(); + } + + return aliasedSelectors; + } + + private static SelectorNode AliasToTableColumnName(SelectorNode selector) + { + if (selector is ColumnSelectorNode columnSelector) + { + if (columnSelector.Column is ColumnInSelectNode columnInSelect) + { + string persistedColumnName = columnInSelect.GetPersistedColumnName(); + + if (columnInSelect.Name != persistedColumnName) + { + // t1.Id0 => t1.Id0 AS Id + return new ColumnSelectorNode(columnInSelect, persistedColumnName); + } + } + + if (columnSelector.Alias != null) + { + // t1."Id" AS Id0 => t1."Id" + return new ColumnSelectorNode(columnSelector.Column, null); + } + } + + return selector; + } + + public override SqlTreeNode DefaultVisit(QueryExpression expression, TableAccessorNode tableAccessor) + { + throw new NotSupportedException($"Expressions of type '{expression.GetType().Name}' are not supported."); + } + + public override SqlTreeNode VisitComparison(ComparisonExpression expression, TableAccessorNode tableAccessor) + { + SqlValueNode left = VisitComparisonTerm(expression.Left, tableAccessor); + SqlValueNode right = VisitComparisonTerm(expression.Right, tableAccessor); + + return new ComparisonNode(expression.Operator, left, right); + } + + private SqlValueNode VisitComparisonTerm(QueryExpression comparisonTerm, TableAccessorNode tableAccessor) + { + if (comparisonTerm is NullConstantExpression) + { + return NullConstantNode.Instance; + } + + SqlTreeNode treeNode = Visit(comparisonTerm, tableAccessor); + + if (treeNode is JoinNode join) + { + return join.InnerColumn; + } + + return (SqlValueNode)treeNode; + } + + public override SqlTreeNode VisitResourceFieldChain(ResourceFieldChainExpression expression, TableAccessorNode tableAccessor) + { + TableAccessorNode currentAccessor = tableAccessor; + + foreach (ResourceFieldAttribute field in expression.Fields) + { + if (field is RelationshipAttribute relationship) + { + currentAccessor = GetOrCreateRelatedTable(currentAccessor, relationship); + } + else if (field is AttrAttribute attribute) + { + ColumnNode? column = currentAccessor.Source.FindColumn(attribute.Property.Name, ColumnType.Scalar, currentAccessor.Source.Alias); + + if (column == null) + { + // Unmapped columns cannot be translated to SQL. + throw new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest) + { + Title = "Sorting or filtering on the requested attribute is unavailable.", + Detail = $"Sorting or filtering on attribute '{attribute.PublicName}' is unavailable because it is unmapped." + }); + } + + return column; + } + } + + return currentAccessor; + } + + public override SqlTreeNode VisitLiteralConstant(LiteralConstantExpression expression, TableAccessorNode tableAccessor) + { + return _queryState.ParameterGenerator.Create(expression.TypedValue); + } + + public override SqlTreeNode VisitLogical(LogicalExpression expression, TableAccessorNode tableAccessor) + { + ReadOnlyCollection terms = VisitSequence(expression.Terms, tableAccessor); + return new LogicalNode(expression.Operator, terms); + } + + private ReadOnlyCollection VisitSequence(IEnumerable source, TableAccessorNode tableAccessor) + where TIn : QueryExpression + where TOut : SqlTreeNode + { + return source.Select(expression => (TOut)Visit(expression, tableAccessor)).ToArray().AsReadOnly(); + } + + public override SqlTreeNode VisitNot(NotExpression expression, TableAccessorNode tableAccessor) + { + var child = (FilterNode)Visit(expression.Child, tableAccessor); + FilterNode filter = child is NotNode notChild ? notChild.Child : new NotNode(child); + + var finder = new NullableAttributeFinder(_queryState.DataModelService); + finder.Visit(expression, null); + + if (finder.AttributesToNullCheck.Count > 0) + { + List orTerms = [filter]; + + foreach (ResourceFieldChainExpression fieldChain in finder.AttributesToNullCheck) + { + var column = (ColumnInTableNode)Visit(fieldChain, tableAccessor); + var isNullCheck = new ComparisonNode(ComparisonOperator.Equals, column, NullConstantNode.Instance); + orTerms.Add(isNullCheck); + } + + return new LogicalNode(LogicalOperator.Or, orTerms.AsReadOnly()); + } + + return filter; + } + + public override SqlTreeNode VisitHas(HasExpression expression, TableAccessorNode tableAccessor) + { + var subSelectBuilder = new SelectStatementBuilder(_queryState) + { + _selectShape = SelectShape.One + }; + + return subSelectBuilder.GetExistsClause(expression, tableAccessor); + } + + private ExistsNode GetExistsClause(HasExpression expression, TableAccessorNode outerTableAccessor) + { + var rightTableAccessor = (TableAccessorNode)Visit(expression.TargetCollection, outerTableAccessor); + + if (expression.Filter != null) + { + var filter = (FilterNode)Visit(expression.Filter, rightTableAccessor); + _whereFilters.Add(filter); + } + + SelectNode select = ToSelect(true, false); + return new ExistsNode(select); + } + + public override SqlTreeNode VisitIsType(IsTypeExpression expression, TableAccessorNode tableAccessor) + { + throw new NotSupportedException("Resource inheritance is not supported."); + } + + public override SqlTreeNode VisitSortElement(SortElementExpression expression, TableAccessorNode tableAccessor) + { + if (expression.Target is CountExpression count) + { + var newCount = (CountNode)Visit(count, tableAccessor); + return new OrderByCountNode(newCount, expression.IsAscending); + } + + if (expression.Target is ResourceFieldChainExpression fieldChain) + { + var column = (ColumnNode)Visit(fieldChain, tableAccessor); + return new OrderByColumnNode(column, expression.IsAscending); + } + + throw new NotSupportedException($"Unsupported sort type '{expression.Target.GetType().Name}' with value '{expression.Target}'."); + } + + public override SqlTreeNode VisitSort(SortExpression expression, TableAccessorNode tableAccessor) + { + ReadOnlyCollection terms = VisitSequence(expression.Elements, tableAccessor); + return new OrderByNode(terms); + } + + public override SqlTreeNode VisitCount(CountExpression expression, TableAccessorNode tableAccessor) + { + var subSelectBuilder = new SelectStatementBuilder(_queryState) + { + _selectShape = SelectShape.Count + }; + + return subSelectBuilder.GetCountClause(expression, tableAccessor); + } + + private CountNode GetCountClause(CountExpression expression, TableAccessorNode outerTableAccessor) + { + _ = Visit(expression.TargetCollection, outerTableAccessor); + + SelectNode select = ToSelect(true, false); + return new CountNode(select); + } + + public override SqlTreeNode VisitMatchText(MatchTextExpression expression, TableAccessorNode tableAccessor) + { + var column = (ColumnNode)Visit(expression.TargetAttribute, tableAccessor); + return new LikeNode(column, expression.MatchKind, (string)expression.TextValue.TypedValue); + } + + public override SqlTreeNode VisitAny(AnyExpression expression, TableAccessorNode tableAccessor) + { + var column = (ColumnNode)Visit(expression.TargetAttribute, tableAccessor); + + ReadOnlyCollection parameters = + VisitSequence(expression.Constants.OrderBy(constant => constant.TypedValue), tableAccessor); + + return parameters.Count == 1 ? new ComparisonNode(ComparisonOperator.Equals, column, parameters[0]) : new InNode(column, parameters); + } + + private sealed class NullableAttributeFinder : QueryExpressionRewriter + { + private readonly IDataModelService _dataModelService; + + public List AttributesToNullCheck { get; } = []; + + public NullableAttributeFinder(IDataModelService dataModelService) + { + ArgumentNullException.ThrowIfNull(dataModelService); + + _dataModelService = dataModelService; + } + + public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpression expression, object? argument) + { + bool seenOptionalToOneRelationship = false; + + foreach (ResourceFieldAttribute field in expression.Fields) + { + if (field is HasOneAttribute hasOneRelationship) + { + RelationshipForeignKey foreignKey = _dataModelService.GetForeignKey(hasOneRelationship); + + if (foreignKey.IsNullable) + { + seenOptionalToOneRelationship = true; + } + } + else if (field is AttrAttribute attribute) + { + if (seenOptionalToOneRelationship || _dataModelService.IsColumnNullable(attribute)) + { + AttributesToNullCheck.Add(expression); + } + } + } + + return base.VisitResourceFieldChain(expression, argument); + } + } + + private sealed class QueryState + { + // Provides access to the underlying data model (tables, columns and foreign keys). + public IDataModelService DataModelService { get; } + + // Used to generate unique aliases for tables. + public TableAliasGenerator TableAliasGenerator { get; } + + // Used to generate unique parameters for constants (to improve query plan caching and guard against SQL injection). + public ParameterGenerator ParameterGenerator { get; } + + public ILoggerFactory LoggerFactory { get; } + + // Prevents importing a table multiple times and enables to reference a table imported by an inner/outer query. + // In case of sub-queries, this may include temporary tables that won't survive in the final query. + public Dictionary> RelatedTables { get; } = []; + + // In case of sub-queries, we track old/new table aliases, so we can rewrite stale references afterwards. + // This cannot be done in the moment itself, because references to tables are on method call stacks. + public Dictionary OldToNewTableAliasMap { get; } = []; + + public QueryState(IDataModelService dataModelService, TableAliasGenerator tableAliasGenerator, ParameterGenerator parameterGenerator, + ILoggerFactory loggerFactory) + { + ArgumentNullException.ThrowIfNull(dataModelService); + ArgumentNullException.ThrowIfNull(tableAliasGenerator); + ArgumentNullException.ThrowIfNull(parameterGenerator); + ArgumentNullException.ThrowIfNull(loggerFactory); + + DataModelService = dataModelService; + TableAliasGenerator = tableAliasGenerator; + ParameterGenerator = parameterGenerator; + LoggerFactory = loggerFactory; + } + + public void Reset() + { + TableAliasGenerator.Reset(); + ParameterGenerator.Reset(); + + RelatedTables.Clear(); + OldToNewTableAliasMap.Clear(); + } + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/SqlQueryBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/SqlQueryBuilder.cs new file mode 100644 index 0000000000..5d3fc634da --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Builders/SqlQueryBuilder.cs @@ -0,0 +1,494 @@ +using System.Text; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore.Queries.Expressions; + +namespace DapperExample.TranslationToSql.Builders; + +/// +/// Converts s into SQL text. +/// +internal sealed class SqlQueryBuilder(DatabaseProvider databaseProvider) : SqlTreeNodeVisitor +{ + private static readonly char[] SpecialCharactersInLikeDefault = + [ + '\\', + '%', + '_' + ]; + + private static readonly char[] SpecialCharactersInLikeSqlServer = + [ + '\\', + '%', + '_', + '[', + ']' + ]; + + private readonly DatabaseProvider _databaseProvider = databaseProvider; + private readonly Dictionary _parametersByName = []; + private int _indentDepth; + + private char[] SpecialCharactersInLike => + _databaseProvider == DatabaseProvider.SqlServer ? SpecialCharactersInLikeSqlServer : SpecialCharactersInLikeDefault; + + public IDictionary Parameters => _parametersByName.Values.ToDictionary(parameter => parameter.Name, parameter => parameter.Value); + + public string GetCommand(SqlTreeNode node) + { + ArgumentNullException.ThrowIfNull(node); + + ResetState(); + + var builder = new StringBuilder(); + Visit(node, builder); + return builder.ToString(); + } + + private void ResetState() + { + _parametersByName.Clear(); + _indentDepth = 0; + } + + public override object? VisitSelect(SelectNode node, StringBuilder builder) + { + if (builder.Length > 0) + { + using (Indent()) + { + builder.Append('('); + WriteSelect(node, builder); + } + + AppendOnNewLine(")", builder); + } + else + { + WriteSelect(node, builder); + } + + WriteDeclareAlias(node.Alias, builder); + return null; + } + + private void WriteSelect(SelectNode node, StringBuilder builder) + { + AppendOnNewLine("SELECT ", builder); + + IEnumerable selectors = node.Selectors.SelectMany(selector => selector.Value); + VisitSequence(selectors, builder); + + foreach (TableAccessorNode tableAccessor in node.Selectors.Keys) + { + Visit(tableAccessor, builder); + } + + if (node.Where != null) + { + Visit(node.Where, builder); + } + + if (node.OrderBy != null) + { + Visit(node.OrderBy, builder); + } + } + + public override object? VisitInsert(InsertNode node, StringBuilder builder) + { + AppendOnNewLine("INSERT INTO ", builder); + Visit(node.Table, builder); + builder.Append(" ("); + VisitSequence(node.Assignments.Select(assignment => assignment.Column), builder); + builder.Append(')'); + + ColumnNode idColumn = node.Table.GetIdColumn(node.Table.Alias); + + if (_databaseProvider == DatabaseProvider.SqlServer) + { + AppendOnNewLine("OUTPUT INSERTED.", builder); + Visit(idColumn, builder); + } + + AppendOnNewLine("VALUES (", builder); + VisitSequence(node.Assignments.Select(assignment => assignment.Value), builder); + builder.Append(')'); + + if (_databaseProvider == DatabaseProvider.PostgreSql) + { + AppendOnNewLine("RETURNING ", builder); + Visit(idColumn, builder); + } + else if (_databaseProvider == DatabaseProvider.MySql) + { + builder.Append(';'); + ColumnAssignmentNode? idAssignment = node.Assignments.FirstOrDefault(assignment => assignment.Column == idColumn); + + if (idAssignment != null) + { + AppendOnNewLine("SELECT ", builder); + Visit(idAssignment.Value, builder); + } + else + { + AppendOnNewLine("SELECT LAST_INSERT_ID()", builder); + } + } + + return null; + } + + public override object? VisitUpdate(UpdateNode node, StringBuilder builder) + { + AppendOnNewLine("UPDATE ", builder); + Visit(node.Table, builder); + + AppendOnNewLine("SET ", builder); + VisitSequence(node.Assignments, builder); + + Visit(node.Where, builder); + return null; + } + + public override object? VisitDelete(DeleteNode node, StringBuilder builder) + { + AppendOnNewLine("DELETE FROM ", builder); + Visit(node.Table, builder); + Visit(node.Where, builder); + return null; + } + + public override object? VisitTable(TableNode node, StringBuilder builder) + { + string tableName = FormatIdentifier(node.Name); + builder.Append(tableName); + WriteDeclareAlias(node.Alias, builder); + return null; + } + + public override object? VisitFrom(FromNode node, StringBuilder builder) + { + AppendOnNewLine("FROM ", builder); + Visit(node.Source, builder); + return null; + } + + public override object? VisitJoin(JoinNode node, StringBuilder builder) + { + string joinTypeText = node.JoinType switch + { + JoinType.InnerJoin => "INNER JOIN ", + JoinType.LeftJoin => "LEFT JOIN ", + _ => throw new NotSupportedException($"Unknown join type '{node.JoinType}'.") + }; + + AppendOnNewLine(joinTypeText, builder); + Visit(node.Source, builder); + builder.Append(" ON "); + Visit(node.OuterColumn, builder); + builder.Append(" = "); + Visit(node.InnerColumn, builder); + return null; + } + + public override object? VisitColumnInTable(ColumnInTableNode node, StringBuilder builder) + { + WriteColumn(node, false, builder); + return null; + } + + public override object? VisitColumnInSelect(ColumnInSelectNode node, StringBuilder builder) + { + WriteColumn(node, node.IsVirtual, builder); + return null; + } + + private void WriteColumn(ColumnNode column, bool isVirtualColumn, StringBuilder builder) + { + WriteReferenceAlias(column.TableAlias, builder); + + string name = isVirtualColumn ? column.Name : FormatIdentifier(column.Name); + builder.Append(name); + } + + public override object? VisitColumnSelector(ColumnSelectorNode node, StringBuilder builder) + { + Visit(node.Column, builder); + WriteDeclareAlias(node.Alias, builder); + return null; + } + + public override object? VisitOneSelector(OneSelectorNode node, StringBuilder builder) + { + builder.Append('1'); + WriteDeclareAlias(node.Alias, builder); + return null; + } + + public override object? VisitCountSelector(CountSelectorNode node, StringBuilder builder) + { + builder.Append("COUNT(*)"); + WriteDeclareAlias(node.Alias, builder); + return null; + } + + public override object? VisitWhere(WhereNode node, StringBuilder builder) + { + AppendOnNewLine("WHERE ", builder); + Visit(node.Filter, builder); + return null; + } + + public override object? VisitNot(NotNode node, StringBuilder builder) + { + builder.Append("NOT ("); + Visit(node.Child, builder); + builder.Append(')'); + return null; + } + + public override object? VisitLogical(LogicalNode node, StringBuilder builder) + { + string operatorText = node.Operator switch + { + LogicalOperator.And => "AND", + LogicalOperator.Or => "OR", + _ => throw new NotSupportedException($"Unknown logical operator '{node.Operator}'.") + }; + + builder.Append('('); + Visit(node.Terms[0], builder); + builder.Append(')'); + + foreach (FilterNode nextTerm in node.Terms.Skip(1)) + { + builder.Append($" {operatorText} ("); + Visit(nextTerm, builder); + builder.Append(')'); + } + + return null; + } + + public override object? VisitComparison(ComparisonNode node, StringBuilder builder) + { + string operatorText = node.Operator switch + { + ComparisonOperator.Equals => node.Left is NullConstantNode || node.Right is NullConstantNode ? "IS" : "=", + ComparisonOperator.GreaterThan => ">", + ComparisonOperator.GreaterOrEqual => ">=", + ComparisonOperator.LessThan => "<", + ComparisonOperator.LessOrEqual => "<=", + _ => throw new NotSupportedException($"Unknown comparison operator '{node.Operator}'.") + }; + + Visit(node.Left, builder); + builder.Append($" {operatorText} "); + Visit(node.Right, builder); + return null; + } + + public override object? VisitLike(LikeNode node, StringBuilder builder) + { + Visit(node.Column, builder); + builder.Append(" LIKE '"); + + if (node.MatchKind is TextMatchKind.Contains or TextMatchKind.EndsWith) + { + builder.Append('%'); + } + + string safeValue = node.Text.Replace("'", "''"); + bool requireEscapeClause = node.Text.IndexOfAny(SpecialCharactersInLike) != -1; + + if (requireEscapeClause) + { + foreach (char specialCharacter in SpecialCharactersInLike) + { + safeValue = safeValue.Replace(specialCharacter.ToString(), $@"\{specialCharacter}"); + } + } + + if (requireEscapeClause && _databaseProvider == DatabaseProvider.MySql) + { + safeValue = safeValue.Replace(@"\\", @"\\\\"); + } + + builder.Append(safeValue); + + if (node.MatchKind is TextMatchKind.Contains or TextMatchKind.StartsWith) + { + builder.Append('%'); + } + + builder.Append('\''); + + if (requireEscapeClause) + { + builder.Append(_databaseProvider == DatabaseProvider.MySql ? @" ESCAPE '\\'" : @" ESCAPE '\'"); + } + + return null; + } + + public override object? VisitIn(InNode node, StringBuilder builder) + { + Visit(node.Column, builder); + builder.Append(" IN ("); + VisitSequence(node.Values, builder); + builder.Append(')'); + return null; + } + + public override object? VisitExists(ExistsNode node, StringBuilder builder) + { + builder.Append("EXISTS "); + Visit(node.SubSelect, builder); + return null; + } + + public override object? VisitCount(CountNode node, StringBuilder builder) + { + Visit(node.SubSelect, builder); + return null; + } + + public override object? VisitOrderBy(OrderByNode node, StringBuilder builder) + { + AppendOnNewLine("ORDER BY ", builder); + VisitSequence(node.Terms, builder); + return null; + } + + public override object? VisitOrderByColumn(OrderByColumnNode node, StringBuilder builder) + { + Visit(node.Column, builder); + + if (!node.IsAscending) + { + builder.Append(" DESC"); + } + + return null; + } + + public override object? VisitOrderByCount(OrderByCountNode node, StringBuilder builder) + { + Visit(node.Count, builder); + + if (!node.IsAscending) + { + builder.Append(" DESC"); + } + + return null; + } + + public override object? VisitColumnAssignment(ColumnAssignmentNode node, StringBuilder builder) + { + Visit(node.Column, builder); + builder.Append(" = "); + Visit(node.Value, builder); + return null; + } + + public override object? VisitParameter(ParameterNode node, StringBuilder builder) + { + _parametersByName[node.Name] = node; + + builder.Append(node.Name); + return null; + } + + public override object? VisitNullConstant(NullConstantNode node, StringBuilder builder) + { + builder.Append("NULL"); + return null; + } + + private static void WriteDeclareAlias(string? alias, StringBuilder builder) + { + if (alias != null) + { + builder.Append($" AS {alias}"); + } + } + + private static void WriteReferenceAlias(string? alias, StringBuilder builder) + { + if (alias != null) + { + builder.Append($"{alias}."); + } + } + + private void VisitSequence(IEnumerable elements, StringBuilder builder) + where T : SqlTreeNode + { + bool isFirstElement = true; + + foreach (T element in elements) + { + if (isFirstElement) + { + isFirstElement = false; + } + else + { + builder.Append(", "); + } + + Visit(element, builder); + } + } + + private void AppendOnNewLine(string? value, StringBuilder builder) + { + if (!string.IsNullOrEmpty(value)) + { + if (builder.Length > 0) + { + builder.AppendLine(); + } + + builder.Append(new string(' ', _indentDepth * 4)); + builder.Append(value); + } + } + + private string FormatIdentifier(string value) + { + return FormatIdentifier(value, _databaseProvider); + } + + internal static string FormatIdentifier(string value, DatabaseProvider databaseProvider) + { + return databaseProvider switch + { + // https://www.postgresql.org/docs/current/sql-syntax-lexical.html + DatabaseProvider.PostgreSql => $"\"{value.Replace("\"", "\"\"")}\"", + // https://dev.mysql.com/doc/refman/8.0/en/identifiers.html + DatabaseProvider.MySql => $"`{value.Replace("`", "``")}`", + // https://learn.microsoft.com/en-us/sql/t-sql/functions/quotename-transact-sql?view=sql-server-ver16 + DatabaseProvider.SqlServer => $"[{value.Replace("]", "]]")}]", + _ => throw new NotSupportedException($"Unknown database provider '{databaseProvider}'.") + }; + } + + private RevertIndentOnDispose Indent() + { + _indentDepth++; + return new RevertIndentOnDispose(this); + } + + private sealed class RevertIndentOnDispose(SqlQueryBuilder owner) : IDisposable + { + private readonly SqlQueryBuilder _owner = owner; + + public void Dispose() + { + _owner._indentDepth--; + } + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/StatementBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/StatementBuilder.cs new file mode 100644 index 0000000000..de35ea5ba9 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Builders/StatementBuilder.cs @@ -0,0 +1,32 @@ +using DapperExample.TranslationToSql.DataModel; +using DapperExample.TranslationToSql.Generators; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.TranslationToSql.Builders; + +internal abstract class StatementBuilder +{ + private readonly IDataModelService _dataModelService; + + protected ParameterGenerator ParameterGenerator { get; } = new(); + + protected StatementBuilder(IDataModelService dataModelService) + { + ArgumentNullException.ThrowIfNull(dataModelService); + + _dataModelService = dataModelService; + } + + protected void ResetState() + { + ParameterGenerator.Reset(); + } + + protected TableNode GetTable(ResourceType resourceType, string? alias) + { + IReadOnlyDictionary columnMappings = _dataModelService.GetColumnMappings(resourceType); + return new TableNode(resourceType, columnMappings, alias); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/UpdateClearOneToOneStatementBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/UpdateClearOneToOneStatementBuilder.cs new file mode 100644 index 0000000000..610ae9830d --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Builders/UpdateClearOneToOneStatementBuilder.cs @@ -0,0 +1,42 @@ +using DapperExample.TranslationToSql.DataModel; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; + +namespace DapperExample.TranslationToSql.Builders; + +internal sealed class UpdateClearOneToOneStatementBuilder(IDataModelService dataModelService) + : StatementBuilder(dataModelService) +{ + public UpdateNode Build(ResourceType resourceType, string setColumnName, string whereColumnName, object? whereValue) + { + ArgumentNullException.ThrowIfNull(resourceType); + ArgumentException.ThrowIfNullOrEmpty(setColumnName); + ArgumentException.ThrowIfNullOrEmpty(whereColumnName); + + ResetState(); + + TableNode table = GetTable(resourceType, null); + + ColumnNode setColumn = table.GetColumn(setColumnName, null, table.Alias); + ColumnAssignmentNode columnAssignment = GetColumnAssignment(setColumn); + + ColumnNode whereColumn = table.GetColumn(whereColumnName, null, table.Alias); + WhereNode where = GetWhere(whereColumn, whereValue); + + return new UpdateNode(table, [columnAssignment], where); + } + + private WhereNode GetWhere(ColumnNode column, object? value) + { + ParameterNode whereParameter = ParameterGenerator.Create(value); + var filter = new ComparisonNode(ComparisonOperator.Equals, column, whereParameter); + return new WhereNode(filter); + } + + private ColumnAssignmentNode GetColumnAssignment(ColumnNode setColumn) + { + ParameterNode parameter = ParameterGenerator.Create(null); + return new ColumnAssignmentNode(setColumn, parameter); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Builders/UpdateResourceStatementBuilder.cs b/src/Examples/DapperExample/TranslationToSql/Builders/UpdateResourceStatementBuilder.cs new file mode 100644 index 0000000000..619dba7797 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Builders/UpdateResourceStatementBuilder.cs @@ -0,0 +1,52 @@ +using System.Collections.ObjectModel; +using DapperExample.TranslationToSql.DataModel; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; + +namespace DapperExample.TranslationToSql.Builders; + +internal sealed class UpdateResourceStatementBuilder(IDataModelService dataModelService) + : StatementBuilder(dataModelService) +{ + public UpdateNode Build(ResourceType resourceType, IReadOnlyDictionary columnsToUpdate, params object[] idValues) + { + ArgumentNullException.ThrowIfNull(resourceType); + ArgumentGuard.NotNullNorEmpty(columnsToUpdate); + ArgumentGuard.NotNullNorEmpty(idValues); + + ResetState(); + + TableNode table = GetTable(resourceType, null); + ReadOnlyCollection assignments = GetColumnAssignments(columnsToUpdate, table); + + ColumnNode idColumn = table.GetIdColumn(table.Alias); + WhereNode where = GetWhere(idColumn, idValues); + + return new UpdateNode(table, assignments, where); + } + + private ReadOnlyCollection GetColumnAssignments(IReadOnlyDictionary columnsToUpdate, TableNode table) + { + List assignments = []; + + foreach ((string columnName, object? columnValue) in columnsToUpdate) + { + ColumnNode column = table.GetColumn(columnName, null, table.Alias); + ParameterNode parameter = ParameterGenerator.Create(columnValue); + + var assignment = new ColumnAssignmentNode(column, parameter); + assignments.Add(assignment); + } + + return assignments.AsReadOnly(); + } + + private WhereNode GetWhere(ColumnNode idColumn, IEnumerable idValues) + { + ReadOnlyCollection parameters = idValues.Select(ParameterGenerator.Create).ToArray().AsReadOnly(); + FilterNode filter = parameters.Count == 1 ? new ComparisonNode(ComparisonOperator.Equals, idColumn, parameters[0]) : new InNode(idColumn, parameters); + return new WhereNode(filter); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/DataModel/BaseDataModelService.cs b/src/Examples/DapperExample/TranslationToSql/DataModel/BaseDataModelService.cs new file mode 100644 index 0000000000..3c752ffcbd --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/DataModel/BaseDataModelService.cs @@ -0,0 +1,175 @@ +using System.Collections.ObjectModel; +using System.ComponentModel.DataAnnotations.Schema; +using System.Data; +using System.Data.Common; +using System.Reflection; +using Dapper; +using DapperExample.TranslationToSql.TreeNodes; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.TranslationToSql.DataModel; + +/// +/// Database-agnostic base type that infers additional information, based on foreign keys (provided by derived type) and the JSON:API resource graph. +/// +public abstract class BaseDataModelService : IDataModelService +{ + private readonly Dictionary> _columnMappingsByType = []; + + protected IResourceGraph ResourceGraph { get; } + + public abstract DatabaseProvider DatabaseProvider { get; } + + protected BaseDataModelService(IResourceGraph resourceGraph) + { + ArgumentNullException.ThrowIfNull(resourceGraph); + + ResourceGraph = resourceGraph; + } + + public abstract DbConnection CreateConnection(); + + public abstract RelationshipForeignKey GetForeignKey(RelationshipAttribute relationship); + + protected void Initialize() + { + ScanColumnMappings(); + + if (DatabaseProvider == DatabaseProvider.MySql) + { + // https://stackoverflow.com/questions/12510299/get-datetime-as-utc-with-dapper + SqlMapper.AddTypeHandler(new DapperDateTimeOffsetHandlerForMySql()); + } + } + + private void ScanColumnMappings() + { + foreach (ResourceType resourceType in ResourceGraph.GetResourceTypes()) + { + _columnMappingsByType[resourceType] = ScanColumnMappings(resourceType); + } + } + + private ReadOnlyDictionary ScanColumnMappings(ResourceType resourceType) + { + Dictionary mappings = []; + + foreach (PropertyInfo property in resourceType.ClrType.GetProperties()) + { + if (!IsMapped(property)) + { + continue; + } + + string columnName = property.Name; + ResourceFieldAttribute? field = null; + + RelationshipAttribute? relationship = resourceType.FindRelationshipByPropertyName(property.Name); + + if (relationship != null) + { + RelationshipForeignKey foreignKey = GetForeignKey(relationship); + + if (!foreignKey.IsAtLeftSide) + { + continue; + } + + field = relationship; + columnName = foreignKey.ColumnName; + } + else + { + AttrAttribute? attribute = resourceType.FindAttributeByPropertyName(property.Name); + + if (attribute != null) + { + field = attribute; + } + } + + mappings[columnName] = field; + } + + return mappings.AsReadOnly(); + } + + private static bool IsMapped(PropertyInfo property) + { + return property.GetCustomAttribute() == null; + } + + public IReadOnlyDictionary GetColumnMappings(ResourceType resourceType) + { + if (_columnMappingsByType.TryGetValue(resourceType, out ReadOnlyDictionary? columnMappings)) + { + return columnMappings; + } + + throw new InvalidOperationException($"Column mappings for resource type '{resourceType.ClrType.Name}' are unavailable."); + } + + public object? GetColumnValue(ResourceType resourceType, IIdentifiable resource, string columnName) + { + ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(resource); + AssertSameType(resourceType, resource); + ArgumentException.ThrowIfNullOrEmpty(columnName); + + IReadOnlyDictionary columnMappings = GetColumnMappings(resourceType); + + if (!columnMappings.TryGetValue(columnName, out ResourceFieldAttribute? field)) + { + throw new InvalidOperationException($"Column '{columnName}' not found on resource type '{resourceType}'."); + } + + if (field is AttrAttribute attribute) + { + return attribute.GetValue(resource); + } + + if (field is RelationshipAttribute relationship) + { + var rightResource = (IIdentifiable?)relationship.GetValue(resource); + + if (rightResource == null) + { + return null; + } + + PropertyInfo rightKeyProperty = rightResource.GetClrType().GetProperty(TableSourceNode.IdColumnName)!; + return rightKeyProperty.GetValue(rightResource); + } + + PropertyInfo property = resourceType.ClrType.GetProperty(columnName)!; + return property.GetValue(resource); + } + + private static void AssertSameType(ResourceType resourceType, IIdentifiable resource) + { + Type declaredType = resourceType.ClrType; + Type instanceType = resource.GetClrType(); + + if (instanceType != declaredType) + { + throw new ArgumentException($"Expected resource of type '{declaredType.Name}' instead of '{instanceType.Name}'.", nameof(resource)); + } + } + + public abstract bool IsColumnNullable(AttrAttribute attribute); + + private sealed class DapperDateTimeOffsetHandlerForMySql : SqlMapper.TypeHandler + { + public override void SetValue(IDbDataParameter parameter, DateTimeOffset value) + { + parameter.Value = value; + } + + public override DateTimeOffset Parse(object value) + { + return DateTime.SpecifyKind((DateTime)value, DateTimeKind.Utc); + } + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/DataModel/FromEntitiesDataModelService.cs b/src/Examples/DapperExample/TranslationToSql/DataModel/FromEntitiesDataModelService.cs new file mode 100644 index 0000000000..53bf5e4ae4 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/DataModel/FromEntitiesDataModelService.cs @@ -0,0 +1,141 @@ +using System.Data.Common; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.Data.SqlClient; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using MySqlConnector; +using Npgsql; + +namespace DapperExample.TranslationToSql.DataModel; + +/// +/// Derives foreign keys and connection strings from an existing Entity Framework Core model. +/// +public sealed class FromEntitiesDataModelService(IResourceGraph resourceGraph) + : BaseDataModelService(resourceGraph) +{ + private readonly Dictionary _foreignKeysByRelationship = []; + private readonly Dictionary _columnNullabilityPerAttribute = []; + private string? _connectionString; + private DatabaseProvider? _databaseProvider; + + public override DatabaseProvider DatabaseProvider => AssertHasDatabaseProvider(); + + public void Initialize(DbContext dbContext) + { + _connectionString = dbContext.Database.GetConnectionString(); + + _databaseProvider = dbContext.Database.ProviderName switch + { + "Npgsql.EntityFrameworkCore.PostgreSQL" => DatabaseProvider.PostgreSql, + "Pomelo.EntityFrameworkCore.MySql" => DatabaseProvider.MySql, + "Microsoft.EntityFrameworkCore.SqlServer" => DatabaseProvider.SqlServer, + _ => throw new NotSupportedException($"Unknown database provider '{dbContext.Database.ProviderName}'.") + }; + + ScanForeignKeys(dbContext.Model); + ScanColumnNullability(dbContext.Model); + Initialize(); + } + + private void ScanForeignKeys(IReadOnlyModel entityModel) + { + foreach (RelationshipAttribute relationship in ResourceGraph.GetResourceTypes().SelectMany(resourceType => resourceType.Relationships)) + { + IReadOnlyEntityType? leftEntityType = entityModel.FindEntityType(relationship.LeftType.ClrType); + IReadOnlyNavigation? navigation = leftEntityType?.FindNavigation(relationship.Property.Name); + + if (navigation != null) + { + bool isAtLeftSide = navigation.ForeignKey.DeclaringEntityType.ClrType == relationship.LeftType.ClrType; + string columnName = navigation.ForeignKey.Properties.Single().Name; + bool isNullable = !navigation.ForeignKey.IsRequired; + + var foreignKey = new RelationshipForeignKey(DatabaseProvider, relationship, isAtLeftSide, columnName, isNullable); + _foreignKeysByRelationship[relationship] = foreignKey; + } + } + } + + private void ScanColumnNullability(IReadOnlyModel entityModel) + { + foreach (ResourceType resourceType in ResourceGraph.GetResourceTypes()) + { + ScanColumnNullability(resourceType, entityModel); + } + } + + private void ScanColumnNullability(ResourceType resourceType, IReadOnlyModel entityModel) + { + IReadOnlyEntityType? entityType = entityModel.FindEntityType(resourceType.ClrType); + + if (entityType != null) + { + foreach (AttrAttribute attribute in resourceType.Attributes) + { + IReadOnlyProperty? property = entityType.FindProperty(attribute.Property.Name); + + if (property != null) + { + _columnNullabilityPerAttribute[attribute] = property.IsNullable; + } + } + } + } + + public override DbConnection CreateConnection() + { + string connectionString = AssertHasConnectionString(); + DatabaseProvider databaseProvider = AssertHasDatabaseProvider(); + + return databaseProvider switch + { + DatabaseProvider.PostgreSql => new NpgsqlConnection(connectionString), + DatabaseProvider.MySql => new MySqlConnection(connectionString), + DatabaseProvider.SqlServer => new SqlConnection(connectionString), + _ => throw new NotSupportedException($"Unknown database provider '{databaseProvider}'.") + }; + } + + public override RelationshipForeignKey GetForeignKey(RelationshipAttribute relationship) + { + if (_foreignKeysByRelationship.TryGetValue(relationship, out RelationshipForeignKey? foreignKey)) + { + return foreignKey; + } + + throw new InvalidOperationException( + $"Foreign key mapping for relationship '{relationship.LeftType.ClrType.Name}.{relationship.Property.Name}' is unavailable."); + } + + public override bool IsColumnNullable(AttrAttribute attribute) + { + if (_columnNullabilityPerAttribute.TryGetValue(attribute, out bool isNullable)) + { + return isNullable; + } + + throw new InvalidOperationException($"Attribute '{attribute}' is unavailable."); + } + + private DatabaseProvider AssertHasDatabaseProvider() + { + if (_databaseProvider == null) + { + throw new InvalidOperationException($"Database provider is unavailable. Call {nameof(Initialize)} first."); + } + + return _databaseProvider.Value; + } + + private string AssertHasConnectionString() + { + if (_connectionString == null) + { + throw new InvalidOperationException($"Connection string is unavailable. Call {nameof(Initialize)} first."); + } + + return _connectionString; + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/DataModel/IDataModelService.cs b/src/Examples/DapperExample/TranslationToSql/DataModel/IDataModelService.cs new file mode 100644 index 0000000000..9862c6e28f --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/DataModel/IDataModelService.cs @@ -0,0 +1,24 @@ +using System.Data.Common; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.TranslationToSql.DataModel; + +/// +/// Provides information about the underlying database model, such as foreign key and column names. +/// +public interface IDataModelService +{ + DatabaseProvider DatabaseProvider { get; } + + DbConnection CreateConnection(); + + RelationshipForeignKey GetForeignKey(RelationshipAttribute relationship); + + IReadOnlyDictionary GetColumnMappings(ResourceType resourceType); + + object? GetColumnValue(ResourceType resourceType, IIdentifiable resource, string columnName); + + bool IsColumnNullable(AttrAttribute attribute); +} diff --git a/src/Examples/DapperExample/TranslationToSql/DataModel/RelationshipForeignKey.cs b/src/Examples/DapperExample/TranslationToSql/DataModel/RelationshipForeignKey.cs new file mode 100644 index 0000000000..94633a9c36 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/DataModel/RelationshipForeignKey.cs @@ -0,0 +1,68 @@ +using System.Text; +using DapperExample.TranslationToSql.Builders; +using Humanizer; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.TranslationToSql.DataModel; + +/// +/// Defines foreign key information for a , which is required to produce SQL queries. +/// +[PublicAPI] +public sealed class RelationshipForeignKey +{ + private readonly DatabaseProvider _databaseProvider; + + /// + /// The JSON:API relationship mapped to this foreign key. + /// + public RelationshipAttribute Relationship { get; } + + /// + /// Indicates whether the foreign key column is defined at the left side of the JSON:API relationship. + /// + public bool IsAtLeftSide { get; } + + /// + /// The foreign key column name. + /// + public string ColumnName { get; } + + /// + /// Indicates whether the foreign key column is nullable. + /// + public bool IsNullable { get; } + + public RelationshipForeignKey(DatabaseProvider databaseProvider, RelationshipAttribute relationship, bool isAtLeftSide, string columnName, bool isNullable) + { + ArgumentNullException.ThrowIfNull(relationship); + ArgumentException.ThrowIfNullOrEmpty(columnName); + + _databaseProvider = databaseProvider; + Relationship = relationship; + IsAtLeftSide = isAtLeftSide; + ColumnName = columnName; + IsNullable = isNullable; + } + + public override string ToString() + { + var builder = new StringBuilder(); + builder.Append($"{Relationship.LeftType.ClrType.Name}.{Relationship.Property.Name} => "); + + ResourceType tableType = IsAtLeftSide ? Relationship.LeftType : Relationship.RightType; + + builder.Append(SqlQueryBuilder.FormatIdentifier(tableType.ClrType.Name.Pluralize(), _databaseProvider)); + builder.Append('.'); + builder.Append(SqlQueryBuilder.FormatIdentifier(ColumnName, _databaseProvider)); + + if (IsNullable) + { + builder.Append('?'); + } + + return builder.ToString(); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Generators/ParameterGenerator.cs b/src/Examples/DapperExample/TranslationToSql/Generators/ParameterGenerator.cs new file mode 100644 index 0000000000..577f1d2e3e --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Generators/ParameterGenerator.cs @@ -0,0 +1,25 @@ +using DapperExample.TranslationToSql.TreeNodes; + +namespace DapperExample.TranslationToSql.Generators; + +/// +/// Generates a SQL parameter with a unique name. +/// +internal sealed class ParameterGenerator +{ + private readonly ParameterNameGenerator _nameGenerator = new(); + + public ParameterNode Create(object? value) + { + string name = _nameGenerator.GetNext(); + return new ParameterNode(name, value); + } + + public void Reset() + { + _nameGenerator.Reset(); + } + + private sealed class ParameterNameGenerator() + : UniqueNameGenerator("@p"); +} diff --git a/src/Examples/DapperExample/TranslationToSql/Generators/TableAliasGenerator.cs b/src/Examples/DapperExample/TranslationToSql/Generators/TableAliasGenerator.cs new file mode 100644 index 0000000000..ff302caf10 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Generators/TableAliasGenerator.cs @@ -0,0 +1,7 @@ +namespace DapperExample.TranslationToSql.Generators; + +/// +/// Generates a SQL table alias with a unique name. +/// +internal sealed class TableAliasGenerator() + : UniqueNameGenerator("t"); diff --git a/src/Examples/DapperExample/TranslationToSql/Generators/UniqueNameGenerator.cs b/src/Examples/DapperExample/TranslationToSql/Generators/UniqueNameGenerator.cs new file mode 100644 index 0000000000..5254f4a024 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Generators/UniqueNameGenerator.cs @@ -0,0 +1,24 @@ +namespace DapperExample.TranslationToSql.Generators; + +internal abstract class UniqueNameGenerator +{ + private readonly string _prefix; + private int _lastIndex; + + protected UniqueNameGenerator(string prefix) + { + ArgumentException.ThrowIfNullOrEmpty(prefix); + + _prefix = prefix; + } + + public string GetNext() + { + return $"{_prefix}{++_lastIndex}"; + } + + public void Reset() + { + _lastIndex = 0; + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/ParameterFormatter.cs b/src/Examples/DapperExample/TranslationToSql/ParameterFormatter.cs new file mode 100644 index 0000000000..5dc1591bca --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/ParameterFormatter.cs @@ -0,0 +1,67 @@ +using System.Text; +using JsonApiDotNetCore.Resources; + +namespace DapperExample.TranslationToSql; + +/// +/// Converts a SQL parameter into human-readable text. Used for diagnostic purposes. +/// +internal sealed class ParameterFormatter +{ + private static readonly HashSet NumericTypes = + [ + typeof(bool), + typeof(int), + typeof(uint), + typeof(long), + typeof(ulong), + typeof(short), + typeof(ushort), + typeof(sbyte), + typeof(float), + typeof(double), + typeof(decimal) + ]; + + public string Format(string parameterName, object? parameterValue) + { + StringBuilder builder = new(); + builder.Append($"{parameterName} = "); + WriteValue(parameterValue, builder); + return builder.ToString(); + } + + private void WriteValue(object? parameterValue, StringBuilder builder) + { + if (parameterValue == null) + { + builder.Append("null"); + } + else if (parameterValue is char) + { + builder.Append($"'{parameterValue}'"); + } + else if (parameterValue is byte byteValue) + { + builder.Append($"0x{byteValue:X2}"); + } + else if (parameterValue is Enum) + { + builder.Append($"{parameterValue.GetType().Name}.{parameterValue}"); + } + else + { + string value = (string)RuntimeTypeConverter.ConvertType(parameterValue, typeof(string))!; + + if (NumericTypes.Contains(parameterValue.GetType())) + { + builder.Append(value); + } + else + { + string escapedValue = value.Replace("'", "''"); + builder.Append($"'{escapedValue}'"); + } + } + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/SqlCommand.cs b/src/Examples/DapperExample/TranslationToSql/SqlCommand.cs new file mode 100644 index 0000000000..735da0a55f --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/SqlCommand.cs @@ -0,0 +1,22 @@ +using JetBrains.Annotations; + +namespace DapperExample.TranslationToSql; + +/// +/// Represents a parameterized SQL query. +/// +[PublicAPI] +public sealed class SqlCommand +{ + public string Statement { get; } + public IDictionary Parameters { get; } + + internal SqlCommand(string statement, IDictionary parameters) + { + ArgumentNullException.ThrowIfNull(statement); + ArgumentNullException.ThrowIfNull(parameters); + + Statement = statement; + Parameters = parameters; + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/SqlTreeNodeVisitor.cs b/src/Examples/DapperExample/TranslationToSql/SqlTreeNodeVisitor.cs new file mode 100644 index 0000000000..24a129189d --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/SqlTreeNodeVisitor.cs @@ -0,0 +1,151 @@ +using DapperExample.TranslationToSql.TreeNodes; +using JetBrains.Annotations; + +namespace DapperExample.TranslationToSql; + +/// +/// Implements the visitor design pattern that enables traversing a tree. +/// +[PublicAPI] +internal abstract class SqlTreeNodeVisitor +{ + public virtual TResult Visit(SqlTreeNode node, TArgument argument) + { + return node.Accept(this, argument); + } + + public virtual TResult DefaultVisit(SqlTreeNode node, TArgument argument) + { + return default!; + } + + public virtual TResult VisitSelect(SelectNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitInsert(InsertNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitUpdate(UpdateNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitDelete(DeleteNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitTable(TableNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitFrom(FromNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitJoin(JoinNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitColumnInTable(ColumnInTableNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitColumnInSelect(ColumnInSelectNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitColumnSelector(ColumnSelectorNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitOneSelector(OneSelectorNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitCountSelector(CountSelectorNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitWhere(WhereNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitNot(NotNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitLogical(LogicalNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitComparison(ComparisonNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitLike(LikeNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitIn(InNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitExists(ExistsNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitCount(CountNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitOrderBy(OrderByNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitOrderByColumn(OrderByColumnNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitOrderByCount(OrderByCountNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitColumnAssignment(ColumnAssignmentNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitParameter(ParameterNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } + + public virtual TResult VisitNullConstant(NullConstantNode node, TArgument argument) + { + return DefaultVisit(node, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Transformations/ColumnSelectorUsageCollector.cs b/src/Examples/DapperExample/TranslationToSql/Transformations/ColumnSelectorUsageCollector.cs new file mode 100644 index 0000000000..e7c7799700 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Transformations/ColumnSelectorUsageCollector.cs @@ -0,0 +1,171 @@ +using DapperExample.TranslationToSql.TreeNodes; + +namespace DapperExample.TranslationToSql.Transformations; + +/// +/// Collects all s in selectors that are referenced elsewhere in the query. +/// +internal sealed partial class ColumnSelectorUsageCollector : SqlTreeNodeVisitor +{ + private readonly HashSet _usedColumns = []; + private readonly ILogger _logger; + + public ISet UsedColumns => _usedColumns; + + public ColumnSelectorUsageCollector(ILoggerFactory loggerFactory) + { + ArgumentNullException.ThrowIfNull(loggerFactory); + + _logger = loggerFactory.CreateLogger(); + } + + public void Collect(SelectNode select) + { + ArgumentNullException.ThrowIfNull(select); + + LogStarted(); + + _usedColumns.Clear(); + InnerVisit(select, ColumnVisitMode.Reference); + + LogFinished(); + } + + public override object? VisitSelect(SelectNode node, ColumnVisitMode mode) + { + foreach ((TableAccessorNode tableAccessor, IReadOnlyList tableSelectors) in node.Selectors) + { + InnerVisit(tableAccessor, mode); + VisitSequence(tableSelectors, ColumnVisitMode.Declaration); + } + + InnerVisit(node.Where, mode); + InnerVisit(node.OrderBy, mode); + return null; + } + + public override object? VisitFrom(FromNode node, ColumnVisitMode mode) + { + InnerVisit(node.Source, mode); + return null; + } + + public override object? VisitJoin(JoinNode node, ColumnVisitMode mode) + { + InnerVisit(node.Source, mode); + InnerVisit(node.OuterColumn, mode); + InnerVisit(node.InnerColumn, mode); + return null; + } + + public override object? VisitColumnInSelect(ColumnInSelectNode node, ColumnVisitMode mode) + { + InnerVisit(node.Selector, ColumnVisitMode.Reference); + return null; + } + + public override object? VisitColumnSelector(ColumnSelectorNode node, ColumnVisitMode mode) + { + if (mode == ColumnVisitMode.Reference) + { + _usedColumns.Add(node.Column); + LogColumnAdded(node.Column); + } + + InnerVisit(node.Column, mode); + return null; + } + + public override object? VisitWhere(WhereNode node, ColumnVisitMode mode) + { + InnerVisit(node.Filter, mode); + return null; + } + + public override object? VisitNot(NotNode node, ColumnVisitMode mode) + { + InnerVisit(node.Child, mode); + return null; + } + + public override object? VisitLogical(LogicalNode node, ColumnVisitMode mode) + { + VisitSequence(node.Terms, mode); + return null; + } + + public override object? VisitComparison(ComparisonNode node, ColumnVisitMode mode) + { + InnerVisit(node.Left, mode); + InnerVisit(node.Right, mode); + return null; + } + + public override object? VisitLike(LikeNode node, ColumnVisitMode mode) + { + InnerVisit(node.Column, mode); + return null; + } + + public override object? VisitIn(InNode node, ColumnVisitMode mode) + { + InnerVisit(node.Column, mode); + VisitSequence(node.Values, mode); + return null; + } + + public override object? VisitExists(ExistsNode node, ColumnVisitMode mode) + { + InnerVisit(node.SubSelect, mode); + return null; + } + + public override object? VisitCount(CountNode node, ColumnVisitMode mode) + { + InnerVisit(node.SubSelect, mode); + return null; + } + + public override object? VisitOrderBy(OrderByNode node, ColumnVisitMode mode) + { + VisitSequence(node.Terms, mode); + return null; + } + + public override object? VisitOrderByColumn(OrderByColumnNode node, ColumnVisitMode mode) + { + InnerVisit(node.Column, mode); + return null; + } + + public override object? VisitOrderByCount(OrderByCountNode node, ColumnVisitMode mode) + { + InnerVisit(node.Count, mode); + return null; + } + + private void InnerVisit(SqlTreeNode? node, ColumnVisitMode mode) + { + if (node != null) + { + Visit(node, mode); + } + } + + private void VisitSequence(IEnumerable nodes, ColumnVisitMode mode) + { + foreach (SqlTreeNode node in nodes) + { + InnerVisit(node, mode); + } + } + + [LoggerMessage(Level = LogLevel.Debug, Message = "Started collection of used columns.")] + private partial void LogStarted(); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Finished collection of used columns.")] + private partial void LogFinished(); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Added used column {Column}.")] + private partial void LogColumnAdded(ColumnNode column); +} diff --git a/src/Examples/DapperExample/TranslationToSql/Transformations/ColumnVisitMode.cs b/src/Examples/DapperExample/TranslationToSql/Transformations/ColumnVisitMode.cs new file mode 100644 index 0000000000..6b0e8f8e5c --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Transformations/ColumnVisitMode.cs @@ -0,0 +1,14 @@ +namespace DapperExample.TranslationToSql.Transformations; + +internal enum ColumnVisitMode +{ + /// + /// Definition of a column in a SQL query. + /// + Declaration, + + /// + /// Usage of a column in a SQL query. + /// + Reference +} diff --git a/src/Examples/DapperExample/TranslationToSql/Transformations/LogicalCombinator.cs b/src/Examples/DapperExample/TranslationToSql/Transformations/LogicalCombinator.cs new file mode 100644 index 0000000000..95359462f7 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Transformations/LogicalCombinator.cs @@ -0,0 +1,58 @@ +using DapperExample.TranslationToSql.TreeNodes; + +namespace DapperExample.TranslationToSql.Transformations; + +/// +/// Collapses nested logical filters. This turns "A AND (B AND C)" into "A AND B AND C". +/// +internal sealed class LogicalCombinator : SqlTreeNodeVisitor +{ + public FilterNode Collapse(FilterNode filter) + { + return TypedVisit(filter); + } + + public override SqlTreeNode VisitLogical(LogicalNode node, object? argument) + { + List newTerms = []; + + foreach (FilterNode newTerm in node.Terms.Select(TypedVisit)) + { + if (newTerm is LogicalNode logicalTerm && logicalTerm.Operator == node.Operator) + { + newTerms.AddRange(logicalTerm.Terms); + } + else + { + newTerms.Add(newTerm); + } + } + + return new LogicalNode(node.Operator, newTerms.AsReadOnly()); + } + + public override SqlTreeNode DefaultVisit(SqlTreeNode node, object? argument) + { + return node; + } + + public override SqlTreeNode VisitNot(NotNode node, object? argument) + { + FilterNode newChild = TypedVisit(node.Child); + return new NotNode(newChild); + } + + public override SqlTreeNode VisitComparison(ComparisonNode node, object? argument) + { + SqlValueNode newLeft = TypedVisit(node.Left); + SqlValueNode newRight = TypedVisit(node.Right); + + return new ComparisonNode(node.Operator, newLeft, newRight); + } + + private T TypedVisit(T node) + where T : SqlTreeNode + { + return (T)Visit(node, null); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Transformations/StaleColumnReferenceRewriter.cs b/src/Examples/DapperExample/TranslationToSql/Transformations/StaleColumnReferenceRewriter.cs new file mode 100644 index 0000000000..e364203684 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Transformations/StaleColumnReferenceRewriter.cs @@ -0,0 +1,306 @@ +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using DapperExample.TranslationToSql.TreeNodes; + +namespace DapperExample.TranslationToSql.Transformations; + +/// +/// Updates references to stale columns in sub-queries, by pulling them out until in scope. +/// +/// +///

+/// Regular query: +///

+///

+/// Equivalent with sub-query: +/// +///

+/// The reference to t1 in the WHERE clause has become stale and needs to be pulled out into scope, which is t2. +///
+internal sealed partial class StaleColumnReferenceRewriter : SqlTreeNodeVisitor +{ + private readonly IReadOnlyDictionary _oldToNewTableAliasMap; + private readonly ILogger _logger; + private readonly Stack> _tablesInScopeStack = new(); + + public StaleColumnReferenceRewriter(IReadOnlyDictionary oldToNewTableAliasMap, ILoggerFactory loggerFactory) + { + ArgumentNullException.ThrowIfNull(oldToNewTableAliasMap); + ArgumentNullException.ThrowIfNull(loggerFactory); + + _oldToNewTableAliasMap = oldToNewTableAliasMap; + _logger = loggerFactory.CreateLogger(); + } + + public SelectNode PullColumnsIntoScope(SelectNode select) + { + _tablesInScopeStack.Clear(); + + return TypedVisit(select, ColumnVisitMode.Reference); + } + + public override SqlTreeNode DefaultVisit(SqlTreeNode node, ColumnVisitMode mode) + { + throw new NotSupportedException($"Nodes of type '{node.GetType().Name}' are not supported."); + } + + public override SqlTreeNode VisitSelect(SelectNode node, ColumnVisitMode mode) + { + IncludeTableAliasInCurrentScope(node); + + using (EnterSelectScope()) + { + ReadOnlyDictionary> selectors = VisitSelectors(node.Selectors, mode); + WhereNode? where = TypedVisit(node.Where, mode); + OrderByNode? orderBy = TypedVisit(node.OrderBy, mode); + return new SelectNode(selectors, where, orderBy, node.Alias); + } + } + + private void IncludeTableAliasInCurrentScope(TableSourceNode tableSource) + { + if (tableSource.Alias != null) + { + Dictionary tablesInScope = _tablesInScopeStack.Peek(); + tablesInScope.Add(tableSource.Alias, tableSource); + } + } + + private PopStackOnDispose> EnterSelectScope() + { + Dictionary newScope = CopyTopStackElement(); + _tablesInScopeStack.Push(newScope); + + return new PopStackOnDispose>(_tablesInScopeStack); + } + + private Dictionary CopyTopStackElement() + { + if (_tablesInScopeStack.Count == 0) + { + return []; + } + + Dictionary topElement = _tablesInScopeStack.Peek(); + return new Dictionary(topElement); + } + + private ReadOnlyDictionary> VisitSelectors( + IReadOnlyDictionary> selectors, ColumnVisitMode mode) + { + Dictionary> newSelectors = []; + + foreach ((TableAccessorNode tableAccessor, IReadOnlyList tableSelectors) in selectors) + { + TableAccessorNode newTableAccessor = TypedVisit(tableAccessor, mode); + ReadOnlyCollection newTableSelectors = VisitSequence(tableSelectors, ColumnVisitMode.Declaration); + + newSelectors.Add(newTableAccessor, newTableSelectors); + } + + return newSelectors.AsReadOnly(); + } + + public override SqlTreeNode VisitTable(TableNode node, ColumnVisitMode mode) + { + IncludeTableAliasInCurrentScope(node); + return node; + } + + public override SqlTreeNode VisitFrom(FromNode node, ColumnVisitMode mode) + { + TableSourceNode source = TypedVisit(node.Source, mode); + return new FromNode(source); + } + + public override SqlTreeNode VisitJoin(JoinNode node, ColumnVisitMode mode) + { + TableSourceNode source = TypedVisit(node.Source, mode); + ColumnNode outerColumn = TypedVisit(node.OuterColumn, mode); + ColumnNode innerColumn = TypedVisit(node.InnerColumn, mode); + return new JoinNode(node.JoinType, source, outerColumn, innerColumn); + } + + public override SqlTreeNode VisitColumnInTable(ColumnInTableNode node, ColumnVisitMode mode) + { + if (mode == ColumnVisitMode.Declaration) + { + return node; + } + + Dictionary tablesInScope = _tablesInScopeStack.Peek(); + return MapColumnInTable(node, tablesInScope); + } + + private ColumnNode MapColumnInTable(ColumnInTableNode column, Dictionary tablesInScope) + { + if (column.TableAlias != null && !tablesInScope.ContainsKey(column.TableAlias)) + { + // Stale column found. Keep pulling out until in scope. + string currentAlias = column.TableAlias; + + while (_oldToNewTableAliasMap.ContainsKey(currentAlias)) + { + currentAlias = _oldToNewTableAliasMap[currentAlias]; + + if (tablesInScope.TryGetValue(currentAlias, out TableSourceNode? currentTable)) + { + ColumnNode? outerColumn = currentTable.FindColumn(column.Name, null, column.TableAlias); + + if (outerColumn != null) + { + LogColumnMapped(column, outerColumn); + return outerColumn; + } + } + } + + string candidateScopes = string.Join(", ", tablesInScope.Select(table => table.Key)); + throw new InvalidOperationException($"Failed to map inaccessible column {column} to any of the tables in scope: {candidateScopes}."); + } + + return column; + } + + public override SqlTreeNode VisitColumnInSelect(ColumnInSelectNode node, ColumnVisitMode mode) + { + if (mode == ColumnVisitMode.Declaration) + { + return node; + } + + ColumnSelectorNode selector = TypedVisit(node.Selector, mode); + return new ColumnInSelectNode(selector, node.TableAlias); + } + + public override SqlTreeNode VisitColumnSelector(ColumnSelectorNode node, ColumnVisitMode mode) + { + ColumnNode column = TypedVisit(node.Column, ColumnVisitMode.Declaration); + return new ColumnSelectorNode(column, node.Alias); + } + + public override SqlTreeNode VisitOneSelector(OneSelectorNode node, ColumnVisitMode mode) + { + return node; + } + + public override SqlTreeNode VisitCountSelector(CountSelectorNode node, ColumnVisitMode mode) + { + return node; + } + + public override SqlTreeNode VisitWhere(WhereNode node, ColumnVisitMode mode) + { + FilterNode filter = TypedVisit(node.Filter, mode); + return new WhereNode(filter); + } + + public override SqlTreeNode VisitNot(NotNode node, ColumnVisitMode mode) + { + FilterNode child = TypedVisit(node.Child, mode); + return new NotNode(child); + } + + public override SqlTreeNode VisitLogical(LogicalNode node, ColumnVisitMode mode) + { + ReadOnlyCollection terms = VisitSequence(node.Terms, mode); + return new LogicalNode(node.Operator, terms); + } + + public override SqlTreeNode VisitComparison(ComparisonNode node, ColumnVisitMode mode) + { + SqlValueNode left = TypedVisit(node.Left, mode); + SqlValueNode right = TypedVisit(node.Right, mode); + return new ComparisonNode(node.Operator, left, right); + } + + public override SqlTreeNode VisitLike(LikeNode node, ColumnVisitMode mode) + { + ColumnNode column = TypedVisit(node.Column, mode); + return new LikeNode(column, node.MatchKind, node.Text); + } + + public override SqlTreeNode VisitIn(InNode node, ColumnVisitMode mode) + { + ColumnNode column = TypedVisit(node.Column, mode); + ReadOnlyCollection values = VisitSequence(node.Values, mode); + return new InNode(column, values); + } + + public override SqlTreeNode VisitExists(ExistsNode node, ColumnVisitMode mode) + { + SelectNode subSelect = TypedVisit(node.SubSelect, mode); + return new ExistsNode(subSelect); + } + + public override SqlTreeNode VisitCount(CountNode node, ColumnVisitMode mode) + { + SelectNode subSelect = TypedVisit(node.SubSelect, mode); + return new CountNode(subSelect); + } + + public override SqlTreeNode VisitOrderBy(OrderByNode node, ColumnVisitMode mode) + { + ReadOnlyCollection terms = VisitSequence(node.Terms, mode); + return new OrderByNode(terms); + } + + public override SqlTreeNode VisitOrderByColumn(OrderByColumnNode node, ColumnVisitMode mode) + { + ColumnNode column = TypedVisit(node.Column, mode); + return new OrderByColumnNode(column, node.IsAscending); + } + + public override SqlTreeNode VisitOrderByCount(OrderByCountNode node, ColumnVisitMode mode) + { + CountNode count = TypedVisit(node.Count, mode); + return new OrderByCountNode(count, node.IsAscending); + } + + public override SqlTreeNode VisitParameter(ParameterNode node, ColumnVisitMode mode) + { + return node; + } + + public override SqlTreeNode VisitNullConstant(NullConstantNode node, ColumnVisitMode mode) + { + return node; + } + + [return: NotNullIfNotNull(nameof(node))] + private T? TypedVisit(T? node, ColumnVisitMode mode) + where T : SqlTreeNode + { + return node != null ? (T)Visit(node, mode) : null; + } + + private ReadOnlyCollection VisitSequence(IEnumerable nodes, ColumnVisitMode mode) + where T : SqlTreeNode + { + return nodes.Select(element => TypedVisit(element, mode)).ToArray().AsReadOnly(); + } + + [LoggerMessage(Level = LogLevel.Debug, Message = "Mapped inaccessible column {FromColumn} to {ToColumn}.")] + private partial void LogColumnMapped(ColumnNode fromColumn, ColumnNode toColumn); + + private sealed class PopStackOnDispose(Stack stack) : IDisposable + { + private readonly Stack _stack = stack; + + public void Dispose() + { + _stack.Pop(); + } + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/Transformations/UnusedSelectorsRewriter.cs b/src/Examples/DapperExample/TranslationToSql/Transformations/UnusedSelectorsRewriter.cs new file mode 100644 index 0000000000..fe0f326209 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/Transformations/UnusedSelectorsRewriter.cs @@ -0,0 +1,228 @@ +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using DapperExample.TranslationToSql.TreeNodes; + +namespace DapperExample.TranslationToSql.Transformations; + +/// +/// Removes unreferenced selectors in sub-queries. +/// +/// +///

+/// Regular query: +///

+///

+/// Equivalent with sub-query: +/// +///

+/// The selectors t1."AccountId" and t1."FirstName" have no references and can be removed. +///
+internal sealed partial class UnusedSelectorsRewriter : SqlTreeNodeVisitor, SqlTreeNode> +{ + private readonly ColumnSelectorUsageCollector _usageCollector; + private readonly ILogger _logger; + private SelectNode _rootSelect = null!; + private bool _hasChanged; + + public UnusedSelectorsRewriter(ILoggerFactory loggerFactory) + { + ArgumentNullException.ThrowIfNull(loggerFactory); + + _usageCollector = new ColumnSelectorUsageCollector(loggerFactory); + _logger = loggerFactory.CreateLogger(); + } + + public SelectNode RemoveUnusedSelectorsInSubQueries(SelectNode select) + { + ArgumentNullException.ThrowIfNull(select); + + _rootSelect = select; + + do + { + _hasChanged = false; + _usageCollector.Collect(_rootSelect); + + LogStarted(); + _rootSelect = TypedVisit(_rootSelect, _usageCollector.UsedColumns); + LogFinished(); + } + while (_hasChanged); + + return _rootSelect; + } + + public override SqlTreeNode DefaultVisit(SqlTreeNode node, ISet usedColumns) + { + return node; + } + + public override SqlTreeNode VisitSelect(SelectNode node, ISet usedColumns) + { + ReadOnlyDictionary> selectors = VisitSelectors(node, usedColumns); + WhereNode? where = TypedVisit(node.Where, usedColumns); + OrderByNode? orderBy = TypedVisit(node.OrderBy, usedColumns); + return new SelectNode(selectors, where, orderBy, node.Alias); + } + + private ReadOnlyDictionary> VisitSelectors(SelectNode select, ISet usedColumns) + { + Dictionary> newSelectors = []; + + foreach ((TableAccessorNode tableAccessor, IReadOnlyList tableSelectors) in select.Selectors) + { + TableAccessorNode newTableAccessor = TypedVisit(tableAccessor, usedColumns); + IReadOnlyList newTableSelectors = select == _rootSelect ? tableSelectors : VisitTableSelectors(tableSelectors, usedColumns); + newSelectors.Add(newTableAccessor, newTableSelectors); + } + + return newSelectors.AsReadOnly(); + } + + private ReadOnlyCollection VisitTableSelectors(IEnumerable selectors, ISet usedColumns) + { + List newTableSelectors = []; + + foreach (SelectorNode selector in selectors) + { + if (selector is ColumnSelectorNode columnSelector) + { + if (!usedColumns.Contains(columnSelector.Column)) + { + LogSelectorRemoved(columnSelector); + _hasChanged = true; + continue; + } + } + + newTableSelectors.Add(selector); + } + + return newTableSelectors.AsReadOnly(); + } + + public override SqlTreeNode VisitFrom(FromNode node, ISet usedColumns) + { + TableSourceNode source = TypedVisit(node.Source, usedColumns); + return new FromNode(source); + } + + public override SqlTreeNode VisitJoin(JoinNode node, ISet usedColumns) + { + TableSourceNode source = TypedVisit(node.Source, usedColumns); + ColumnNode outerColumn = TypedVisit(node.OuterColumn, usedColumns); + ColumnNode innerColumn = TypedVisit(node.InnerColumn, usedColumns); + return new JoinNode(node.JoinType, source, outerColumn, innerColumn); + } + + public override SqlTreeNode VisitColumnInSelect(ColumnInSelectNode node, ISet usedColumns) + { + ColumnSelectorNode selector = TypedVisit(node.Selector, usedColumns); + return new ColumnInSelectNode(selector, node.TableAlias); + } + + public override SqlTreeNode VisitColumnSelector(ColumnSelectorNode node, ISet usedColumns) + { + ColumnNode column = TypedVisit(node.Column, usedColumns); + return new ColumnSelectorNode(column, node.Alias); + } + + public override SqlTreeNode VisitWhere(WhereNode node, ISet usedColumns) + { + FilterNode filter = TypedVisit(node.Filter, usedColumns); + return new WhereNode(filter); + } + + public override SqlTreeNode VisitNot(NotNode node, ISet usedColumns) + { + FilterNode child = TypedVisit(node.Child, usedColumns); + return new NotNode(child); + } + + public override SqlTreeNode VisitLogical(LogicalNode node, ISet usedColumns) + { + ReadOnlyCollection terms = VisitSequence(node.Terms, usedColumns); + return new LogicalNode(node.Operator, terms); + } + + public override SqlTreeNode VisitComparison(ComparisonNode node, ISet usedColumns) + { + SqlValueNode left = TypedVisit(node.Left, usedColumns); + SqlValueNode right = TypedVisit(node.Right, usedColumns); + return new ComparisonNode(node.Operator, left, right); + } + + public override SqlTreeNode VisitLike(LikeNode node, ISet usedColumns) + { + ColumnNode column = TypedVisit(node.Column, usedColumns); + return new LikeNode(column, node.MatchKind, node.Text); + } + + public override SqlTreeNode VisitIn(InNode node, ISet usedColumns) + { + ColumnNode column = TypedVisit(node.Column, usedColumns); + ReadOnlyCollection values = VisitSequence(node.Values, usedColumns); + return new InNode(column, values); + } + + public override SqlTreeNode VisitExists(ExistsNode node, ISet usedColumns) + { + SelectNode subSelect = TypedVisit(node.SubSelect, usedColumns); + return new ExistsNode(subSelect); + } + + public override SqlTreeNode VisitCount(CountNode node, ISet usedColumns) + { + SelectNode subSelect = TypedVisit(node.SubSelect, usedColumns); + return new CountNode(subSelect); + } + + public override SqlTreeNode VisitOrderBy(OrderByNode node, ISet usedColumns) + { + ReadOnlyCollection terms = VisitSequence(node.Terms, usedColumns); + return new OrderByNode(terms); + } + + public override SqlTreeNode VisitOrderByColumn(OrderByColumnNode node, ISet usedColumns) + { + ColumnNode column = TypedVisit(node.Column, usedColumns); + return new OrderByColumnNode(column, node.IsAscending); + } + + public override SqlTreeNode VisitOrderByCount(OrderByCountNode node, ISet usedColumns) + { + CountNode count = TypedVisit(node.Count, usedColumns); + return new OrderByCountNode(count, node.IsAscending); + } + + [return: NotNullIfNotNull(nameof(node))] + private T? TypedVisit(T? node, ISet usedColumns) + where T : SqlTreeNode + { + return node != null ? (T)Visit(node, usedColumns) : null; + } + + private ReadOnlyCollection VisitSequence(IEnumerable nodes, ISet usedColumns) + where T : SqlTreeNode + { + return nodes.Select(element => TypedVisit(element, usedColumns)).ToArray().AsReadOnly(); + } + + [LoggerMessage(Level = LogLevel.Debug, Message = "Started removal of unused selectors.")] + private partial void LogStarted(); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Finished removal of unused selectors.")] + private partial void LogFinished(); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Removing unused selector {Selector}.")] + private partial void LogSelectorRemoved(ColumnSelectorNode selector); +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnAssignmentNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnAssignmentNode.cs new file mode 100644 index 0000000000..3578ea9a55 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnAssignmentNode.cs @@ -0,0 +1,29 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents assignment to a column in an . For example, in: +/// . +/// +internal sealed class ColumnAssignmentNode : SqlTreeNode +{ + public ColumnNode Column { get; } + public SqlValueNode Value { get; } + + public ColumnAssignmentNode(ColumnNode column, SqlValueNode value) + { + ArgumentNullException.ThrowIfNull(column); + ArgumentNullException.ThrowIfNull(value); + + Column = column; + Value = value; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitColumnAssignment(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnInSelectNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnInSelectNode.cs new file mode 100644 index 0000000000..14d82bb1d3 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnInSelectNode.cs @@ -0,0 +1,34 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a reference to a column in a . For example, in: +/// . +/// +internal sealed class ColumnInSelectNode(ColumnSelectorNode selector, string? tableAlias) + : ColumnNode(GetColumnName(selector), selector.Column.Type, tableAlias) +{ + public ColumnSelectorNode Selector { get; } = selector; + + public bool IsVirtual => Selector.Alias != null || Selector.Column is ColumnInSelectNode { IsVirtual: true }; + + private static string GetColumnName(ColumnSelectorNode selector) + { + ArgumentNullException.ThrowIfNull(selector); + + return selector.Identity; + } + + public string GetPersistedColumnName() + { + return Selector.Column is ColumnInSelectNode columnInSelect ? columnInSelect.GetPersistedColumnName() : Selector.Column.Name; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitColumnInSelect(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnInTableNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnInTableNode.cs new file mode 100644 index 0000000000..da29ed97fc --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnInTableNode.cs @@ -0,0 +1,18 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a reference to a column in a . For example, in: +/// . +/// +internal sealed class ColumnInTableNode(string name, ColumnType type, string? tableAlias) + : ColumnNode(name, type, tableAlias) +{ + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitColumnInTable(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnNode.cs new file mode 100644 index 0000000000..da348882a9 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnNode.cs @@ -0,0 +1,31 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the base type for references to columns in s. +/// +internal abstract class ColumnNode : SqlValueNode +{ + public string Name { get; } + public ColumnType Type { get; } + public string? TableAlias { get; } + + protected ColumnNode(string name, ColumnType type, string? tableAlias) + { + ArgumentException.ThrowIfNullOrEmpty(name); + + Name = name; + Type = type; + TableAlias = tableAlias; + } + + public int GetTableAliasIndex() + { + if (TableAlias == null) + { + return -1; + } + + string? number = TableAlias[1..]; + return int.Parse(number); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnSelectorNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnSelectorNode.cs new file mode 100644 index 0000000000..6c8bb9e36a --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnSelectorNode.cs @@ -0,0 +1,29 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a column selector in a . For example, in: +/// . +/// +internal sealed class ColumnSelectorNode : SelectorNode +{ + public ColumnNode Column { get; } + + public string Identity => Alias ?? Column.Name; + + public ColumnSelectorNode(ColumnNode column, string? alias) + : base(alias) + { + ArgumentNullException.ThrowIfNull(column); + + Column = column; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitColumnSelector(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnType.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnType.cs new file mode 100644 index 0000000000..47b3082225 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ColumnType.cs @@ -0,0 +1,17 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Lists the column types used in a . +/// +internal enum ColumnType +{ + /// + /// A scalar (non-relationship) column, for example: FirstName. + /// + Scalar, + + /// + /// A foreign key column, for example: OwnerId. + /// + ForeignKey +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/ComparisonNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ComparisonNode.cs new file mode 100644 index 0000000000..7264189a6d --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ComparisonNode.cs @@ -0,0 +1,30 @@ +using JsonApiDotNetCore.Queries.Expressions; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the comparison of two values. For example: = @p1 +/// ]]>. +/// +internal sealed class ComparisonNode : FilterNode +{ + public ComparisonOperator Operator { get; } + public SqlValueNode Left { get; } + public SqlValueNode Right { get; } + + public ComparisonNode(ComparisonOperator @operator, SqlValueNode left, SqlValueNode right) + { + ArgumentNullException.ThrowIfNull(left); + ArgumentNullException.ThrowIfNull(right); + + Operator = @operator; + Left = left; + Right = right; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitComparison(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/CountNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/CountNode.cs new file mode 100644 index 0000000000..9133969961 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/CountNode.cs @@ -0,0 +1,26 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a count on the number of rows returned from a sub-query. For example, in: +/// @p1 +/// ]]>. +/// +internal sealed class CountNode : SqlValueNode +{ + public SelectNode SubSelect { get; } + + public CountNode(SelectNode subSelect) + { + ArgumentNullException.ThrowIfNull(subSelect); + + SubSelect = subSelect; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitCount(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/CountSelectorNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/CountSelectorNode.cs new file mode 100644 index 0000000000..d344659393 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/CountSelectorNode.cs @@ -0,0 +1,18 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a row count selector in a . For example, in: +/// . +/// +internal sealed class CountSelectorNode(string? alias) + : SelectorNode(alias) +{ + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitCountSelector(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/DeleteNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/DeleteNode.cs new file mode 100644 index 0000000000..512e0e7321 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/DeleteNode.cs @@ -0,0 +1,26 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a DELETE FROM clause. For example: . +/// +internal sealed class DeleteNode : SqlTreeNode +{ + public TableNode Table { get; } + public WhereNode Where { get; } + + public DeleteNode(TableNode table, WhereNode where) + { + ArgumentNullException.ThrowIfNull(table); + ArgumentNullException.ThrowIfNull(where); + + Table = table; + Where = where; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitDelete(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/ExistsNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ExistsNode.cs new file mode 100644 index 0000000000..96087886f9 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ExistsNode.cs @@ -0,0 +1,26 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a filter on whether a sub-query contains rows. For example, in: +/// . +/// +internal sealed class ExistsNode : FilterNode +{ + public SelectNode SubSelect { get; } + + public ExistsNode(SelectNode subSelect) + { + ArgumentNullException.ThrowIfNull(subSelect); + + SubSelect = subSelect; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitExists(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/FilterNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/FilterNode.cs new file mode 100644 index 0000000000..92a24bac6d --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/FilterNode.cs @@ -0,0 +1,6 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the base type for filters that return a boolean value. +/// +internal abstract class FilterNode : SqlTreeNode; diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/FromNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/FromNode.cs new file mode 100644 index 0000000000..9e39a9d516 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/FromNode.cs @@ -0,0 +1,15 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a FROM clause. For example: . +/// +internal sealed class FromNode(TableSourceNode source) + : TableAccessorNode(source) +{ + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitFrom(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/InNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/InNode.cs new file mode 100644 index 0000000000..114a141eaa --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/InNode.cs @@ -0,0 +1,28 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a filter that matches one value in a candidate set. For example: . +/// +internal sealed class InNode : FilterNode +{ + public ColumnNode Column { get; } + public IReadOnlyList Values { get; } + + public InNode(ColumnNode column, IReadOnlyList values) + { + ArgumentNullException.ThrowIfNull(column); + ArgumentGuard.NotNullNorEmpty(values); + + Column = column; + Values = values; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitIn(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/InsertNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/InsertNode.cs new file mode 100644 index 0000000000..f85857f677 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/InsertNode.cs @@ -0,0 +1,28 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents an INSERT INTO clause. For example: . +/// +internal sealed class InsertNode : SqlTreeNode +{ + public TableNode Table { get; } + public IReadOnlyCollection Assignments { get; } + + public InsertNode(TableNode table, IReadOnlyCollection assignments) + { + ArgumentNullException.ThrowIfNull(table); + ArgumentGuard.NotNullNorEmpty(assignments); + + Table = table; + Assignments = assignments; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitInsert(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/JoinNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/JoinNode.cs new file mode 100644 index 0000000000..48140a0c04 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/JoinNode.cs @@ -0,0 +1,29 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a JOIN clause. For example: . +/// +internal sealed class JoinNode : TableAccessorNode +{ + public JoinType JoinType { get; } + public ColumnNode OuterColumn { get; } + public ColumnNode InnerColumn { get; } + + public JoinNode(JoinType joinType, TableSourceNode source, ColumnNode outerColumn, ColumnNode innerColumn) + : base(source) + { + ArgumentNullException.ThrowIfNull(outerColumn); + ArgumentNullException.ThrowIfNull(innerColumn); + + JoinType = joinType; + OuterColumn = outerColumn; + InnerColumn = innerColumn; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitJoin(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/JoinType.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/JoinType.cs new file mode 100644 index 0000000000..3a3be7369d --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/JoinType.cs @@ -0,0 +1,7 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +internal enum JoinType +{ + LeftJoin, + InnerJoin +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/LikeNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/LikeNode.cs new file mode 100644 index 0000000000..f713e687fc --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/LikeNode.cs @@ -0,0 +1,30 @@ +using JsonApiDotNetCore.Queries.Expressions; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a sub-string match filter. For example: . +/// +internal sealed class LikeNode : FilterNode +{ + public ColumnNode Column { get; } + public TextMatchKind MatchKind { get; } + public string Text { get; } + + public LikeNode(ColumnNode column, TextMatchKind matchKind, string text) + { + ArgumentNullException.ThrowIfNull(column); + ArgumentNullException.ThrowIfNull(text); + + Column = column; + MatchKind = matchKind; + Text = text; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitLike(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/LogicalNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/LogicalNode.cs new file mode 100644 index 0000000000..ebf0554167 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/LogicalNode.cs @@ -0,0 +1,37 @@ +using JsonApiDotNetCore.Queries.Expressions; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a logical AND/OR filter. For example: . +/// +internal sealed class LogicalNode : FilterNode +{ + public LogicalOperator Operator { get; } + public IReadOnlyList Terms { get; } + + public LogicalNode(LogicalOperator @operator, params FilterNode[] terms) + : this(@operator, terms.AsReadOnly()) + { + } + + public LogicalNode(LogicalOperator @operator, IReadOnlyList terms) + { + ArgumentNullException.ThrowIfNull(terms); + + if (terms.Count < 2) + { + throw new ArgumentException("At least two terms are required.", nameof(terms)); + } + + Operator = @operator; + Terms = terms; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitLogical(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/NotNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/NotNode.cs new file mode 100644 index 0000000000..f1f2e9ed22 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/NotNode.cs @@ -0,0 +1,23 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the logical negation of another filter. For example: . +/// +internal sealed class NotNode : FilterNode +{ + public FilterNode Child { get; } + + public NotNode(FilterNode child) + { + ArgumentNullException.ThrowIfNull(child); + + Child = child; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitNot(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/NullConstantNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/NullConstantNode.cs new file mode 100644 index 0000000000..8d345d2563 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/NullConstantNode.cs @@ -0,0 +1,18 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the value NULL. +/// +internal sealed class NullConstantNode : SqlValueNode +{ + public static readonly NullConstantNode Instance = new(); + + private NullConstantNode() + { + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitNullConstant(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/OneSelectorNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/OneSelectorNode.cs new file mode 100644 index 0000000000..ac9e75d44d --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/OneSelectorNode.cs @@ -0,0 +1,18 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the ordinal selector for the first, unnamed column in a . For example, in: +/// . +/// +internal sealed class OneSelectorNode(string? alias) + : SelectorNode(alias) +{ + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitOneSelector(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByColumnNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByColumnNode.cs new file mode 100644 index 0000000000..a62094ae5b --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByColumnNode.cs @@ -0,0 +1,27 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents ordering on a column in an . For example, in: +/// . +/// +internal sealed class OrderByColumnNode : OrderByTermNode +{ + public ColumnNode Column { get; } + + public OrderByColumnNode(ColumnNode column, bool isAscending) + : base(isAscending) + { + ArgumentNullException.ThrowIfNull(column); + + Column = column; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitOrderByColumn(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByCountNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByCountNode.cs new file mode 100644 index 0000000000..c044d98ba9 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByCountNode.cs @@ -0,0 +1,27 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents ordering on the number of rows returned from a sub-query in an . For example, +/// in: . +/// +internal sealed class OrderByCountNode : OrderByTermNode +{ + public CountNode Count { get; } + + public OrderByCountNode(CountNode count, bool isAscending) + : base(isAscending) + { + ArgumentNullException.ThrowIfNull(count); + + Count = count; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitOrderByCount(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByNode.cs new file mode 100644 index 0000000000..dc80ea4395 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByNode.cs @@ -0,0 +1,25 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents an ORDER BY clause. For example: . +/// +internal sealed class OrderByNode : SqlTreeNode +{ + public IReadOnlyList Terms { get; } + + public OrderByNode(IReadOnlyList terms) + { + ArgumentGuard.NotNullNorEmpty(terms); + + Terms = terms; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitOrderBy(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByTermNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByTermNode.cs new file mode 100644 index 0000000000..89fb4c219d --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/OrderByTermNode.cs @@ -0,0 +1,9 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the base type for terms in an . +/// +internal abstract class OrderByTermNode(bool isAscending) : SqlTreeNode +{ + public bool IsAscending { get; } = isAscending; +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/ParameterNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ParameterNode.cs new file mode 100644 index 0000000000..6e206fa04d --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/ParameterNode.cs @@ -0,0 +1,37 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the name and value of a parameter. For example: . +/// +internal sealed class ParameterNode : SqlValueNode +{ + private static readonly ParameterFormatter Formatter = new(); + + public string Name { get; } + public object? Value { get; } + + public ParameterNode(string name, object? value) + { + ArgumentException.ThrowIfNullOrEmpty(name); + + if (!name.StartsWith('@') || name.Length < 2) + { + throw new ArgumentException("Parameter name must start with an '@' symbol and not be empty.", nameof(name)); + } + + Name = name; + Value = value; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitParameter(this, argument); + } + + public override string ToString() + { + return Formatter.Format(Name, Value); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/SelectNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/SelectNode.cs new file mode 100644 index 0000000000..add1ddc433 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/SelectNode.cs @@ -0,0 +1,70 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a SELECT clause, which is a shaped selection of rows from database tables. For example: +/// @p1 +/// ORDER BY t1.Age, t1.LastName +/// ]]>. +/// +internal sealed class SelectNode : TableSourceNode +{ + private readonly List _columns = []; + + public IReadOnlyDictionary> Selectors { get; } + public WhereNode? Where { get; } + public OrderByNode? OrderBy { get; } + + public override IReadOnlyList Columns => _columns.AsReadOnly(); + + public SelectNode(IReadOnlyDictionary> selectors, WhereNode? where, OrderByNode? orderBy, string? alias) + : base(alias) + { + ArgumentGuard.NotNullNorEmpty(selectors); + + Selectors = selectors; + Where = where; + OrderBy = orderBy; + + ReadSelectorColumns(selectors); + } + + private void ReadSelectorColumns(IReadOnlyDictionary> selectors) + { + foreach (ColumnSelectorNode columnSelector in selectors.SelectMany(selector => selector.Value).OfType()) + { + var column = new ColumnInSelectNode(columnSelector, Alias); + _columns.Add(column); + } + } + + public override ColumnNode? FindColumn(string persistedColumnName, ColumnType? type, string? innerTableAlias) + { + if (innerTableAlias == Alias) + { + return Columns.FirstOrDefault(column => column.GetPersistedColumnName() == persistedColumnName && (type == null || column.Type == type)); + } + + foreach (TableSourceNode tableSource in Selectors.Keys.Select(tableAccessor => tableAccessor.Source)) + { + ColumnNode? innerColumn = tableSource.FindColumn(persistedColumnName, type, innerTableAlias); + + if (innerColumn != null) + { + ColumnInSelectNode outerColumn = Columns.Single(column => column.Selector.Column == innerColumn); + return outerColumn; + } + } + + return null; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitSelect(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/SelectorNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/SelectorNode.cs new file mode 100644 index 0000000000..44ee14ea17 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/SelectorNode.cs @@ -0,0 +1,9 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the base type for selectors in a . +/// +internal abstract class SelectorNode(string? alias) : SqlTreeNode +{ + public string? Alias { get; } = alias; +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/SqlTreeNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/SqlTreeNode.cs new file mode 100644 index 0000000000..3b2053a963 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/SqlTreeNode.cs @@ -0,0 +1,18 @@ +using DapperExample.TranslationToSql.Builders; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the base type for all nodes in a SQL query. +/// +internal abstract class SqlTreeNode +{ + public abstract TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument); + + public override string ToString() + { + // This is only used for debugging purposes. + var queryBuilder = new SqlQueryBuilder(DatabaseProvider.PostgreSql); + return queryBuilder.GetCommand(this); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/SqlValueNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/SqlValueNode.cs new file mode 100644 index 0000000000..da1b097757 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/SqlValueNode.cs @@ -0,0 +1,6 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the base type for values, such as parameters, column references and NULL. +/// +internal abstract class SqlValueNode : SqlTreeNode; diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/TableAccessorNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/TableAccessorNode.cs new file mode 100644 index 0000000000..2822749230 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/TableAccessorNode.cs @@ -0,0 +1,16 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the base type for accessors to tabular data, such as FROM and JOIN. +/// +internal abstract class TableAccessorNode : SqlTreeNode +{ + public TableSourceNode Source { get; } + + protected TableAccessorNode(TableSourceNode source) + { + ArgumentNullException.ThrowIfNull(source); + + Source = source; + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/TableNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/TableNode.cs new file mode 100644 index 0000000000..9fca1971cb --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/TableNode.cs @@ -0,0 +1,62 @@ +using Humanizer; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a reference to a database table. For example, in: +/// . +/// +internal sealed class TableNode : TableSourceNode +{ + private readonly ResourceType _resourceType; + private readonly IReadOnlyDictionary _columnMappings; + private readonly List _columns = []; + + public string Name => _resourceType.ClrType.Name.Pluralize(); + + public override IReadOnlyList Columns => _columns.AsReadOnly(); + + public TableNode(ResourceType resourceType, IReadOnlyDictionary columnMappings, string? alias) + : base(alias) + { + ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(columnMappings); + + _resourceType = resourceType; + _columnMappings = columnMappings; + + ReadColumnMappings(); + } + + private void ReadColumnMappings() + { + foreach ((string columnName, ResourceFieldAttribute? field) in _columnMappings) + { + ColumnType columnType = field is RelationshipAttribute ? ColumnType.ForeignKey : ColumnType.Scalar; + var column = new ColumnInTableNode(columnName, columnType, Alias); + + _columns.Add(column); + } + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitTable(this, argument); + } + + public override ColumnNode? FindColumn(string persistedColumnName, ColumnType? type, string? innerTableAlias) + { + if (innerTableAlias != Alias) + { + return null; + } + + return Columns.FirstOrDefault(column => column.Name == persistedColumnName && (type == null || column.Type == type)); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/TableSourceNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/TableSourceNode.cs new file mode 100644 index 0000000000..62ff5ad3ef --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/TableSourceNode.cs @@ -0,0 +1,33 @@ +using JsonApiDotNetCore.Resources; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents the base type for tabular data sources, such as database tables and sub-queries. +/// +internal abstract class TableSourceNode(string? alias) : SqlTreeNode +{ + public const string IdColumnName = nameof(Identifiable.Id); + + public abstract IReadOnlyList Columns { get; } + public string? Alias { get; } = alias; + + public ColumnNode GetIdColumn(string? innerTableAlias) + { + return GetColumn(IdColumnName, ColumnType.Scalar, innerTableAlias); + } + + public ColumnNode GetColumn(string persistedColumnName, ColumnType? type, string? innerTableAlias) + { + ColumnNode? column = FindColumn(persistedColumnName, type, innerTableAlias); + + if (column == null) + { + throw new ArgumentException($"Column '{persistedColumnName}' not found.", nameof(persistedColumnName)); + } + + return column; + } + + public abstract ColumnNode? FindColumn(string persistedColumnName, ColumnType? type, string? innerTableAlias); +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/UpdateNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/UpdateNode.cs new file mode 100644 index 0000000000..7f9b1b79a4 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/UpdateNode.cs @@ -0,0 +1,31 @@ +using JsonApiDotNetCore; + +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents an UPDATE clause. For example: . +/// +internal sealed class UpdateNode : SqlTreeNode +{ + public TableNode Table { get; } + public IReadOnlyCollection Assignments { get; } + public WhereNode Where { get; } + + public UpdateNode(TableNode table, IReadOnlyCollection assignments, WhereNode where) + { + ArgumentNullException.ThrowIfNull(table); + ArgumentGuard.NotNullNorEmpty(assignments); + ArgumentNullException.ThrowIfNull(where); + + Table = table; + Assignments = assignments; + Where = where; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitUpdate(this, argument); + } +} diff --git a/src/Examples/DapperExample/TranslationToSql/TreeNodes/WhereNode.cs b/src/Examples/DapperExample/TranslationToSql/TreeNodes/WhereNode.cs new file mode 100644 index 0000000000..4e8c4d54e8 --- /dev/null +++ b/src/Examples/DapperExample/TranslationToSql/TreeNodes/WhereNode.cs @@ -0,0 +1,23 @@ +namespace DapperExample.TranslationToSql.TreeNodes; + +/// +/// Represents a WHERE clause. For example: @p1 +/// ]]>. +/// +internal sealed class WhereNode : SqlTreeNode +{ + public FilterNode Filter { get; } + + public WhereNode(FilterNode filter) + { + ArgumentNullException.ThrowIfNull(filter); + + Filter = filter; + } + + public override TResult Accept(SqlTreeNodeVisitor visitor, TArgument argument) + { + return visitor.VisitWhere(this, argument); + } +} diff --git a/src/Examples/DapperExample/appsettings.json b/src/Examples/DapperExample/appsettings.json new file mode 100644 index 0000000000..7854646e7f --- /dev/null +++ b/src/Examples/DapperExample/appsettings.json @@ -0,0 +1,24 @@ +{ + "DatabaseProvider": "PostgreSql", + "ConnectionStrings": { + // docker run --rm --detach --name dapper-example-postgresql-db -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:latest + // docker run --rm --detach --name dapper-example-postgresql-management --link dapper-example-postgresql-db:db -e PGADMIN_DEFAULT_EMAIL=admin@admin.com -e PGADMIN_DEFAULT_PASSWORD=postgres -p 5050:80 dpage/pgadmin4:latest + "DapperExamplePostgreSql": "Host=localhost;Database=DapperExample;User ID=postgres;Password=postgres;Include Error Detail=true", + // docker run --rm --detach --name dapper-example-mysql-db -e MYSQL_ROOT_PASSWORD=mysql -e MYSQL_DATABASE=DapperExample -e MYSQL_USER=mysql -e MYSQL_PASSWORD=mysql -p 3306:3306 mysql:latest + // docker run --rm --detach --name dapper-example-mysql-management --link dapper-example-mysql-db:db -p 8081:80 phpmyadmin/phpmyadmin + "DapperExampleMySql": "Host=localhost;Database=DapperExample;User ID=mysql;Password=mysql;SSL Mode=None;AllowPublicKeyRetrieval=True", + // docker run --rm --detach --name dapper-example-sqlserver -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=Passw0rd!" -p 1433:1433 mcr.microsoft.com/mssql/server:2022-latest + "DapperExampleSqlServer": "Server=localhost;Database=DapperExample;User ID=sa;Password=Passw0rd!;TrustServerCertificate=true" + }, + "Logging": { + "LogLevel": { + "Default": "Warning", + // Include server startup, incoming requests and SQL commands. + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore.Hosting.Diagnostics": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information", + "DapperExample": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Examples/DatabasePerTenantExample/Controllers/EmployeesController.cs b/src/Examples/DatabasePerTenantExample/Controllers/EmployeesController.cs index 5e17afab9b..5bae37f9b9 100644 --- a/src/Examples/DatabasePerTenantExample/Controllers/EmployeesController.cs +++ b/src/Examples/DatabasePerTenantExample/Controllers/EmployeesController.cs @@ -3,13 +3,6 @@ namespace DatabasePerTenantExample.Controllers; -// Workaround for https://youtrack.jetbrains.com/issue/RSRP-487028 -public partial class EmployeesController -{ -} - [DisableRoutingConvention] [Route("api/{tenantName}/employees")] -partial class EmployeesController -{ -} +partial class EmployeesController; diff --git a/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs b/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs index c70fc8655f..7ff84c8e41 100644 --- a/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs +++ b/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs @@ -8,43 +8,37 @@ namespace DatabasePerTenantExample.Data; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class AppDbContext : DbContext +public sealed class AppDbContext(DbContextOptions options, IHttpContextAccessor httpContextAccessor, IConfiguration configuration) + : DbContext(options) { - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly IConfiguration _configuration; + private readonly IHttpContextAccessor _httpContextAccessor = httpContextAccessor; + private readonly IConfiguration _configuration = configuration; private string? _forcedTenantName; public DbSet Employees => Set(); - public AppDbContext(IHttpContextAccessor httpContextAccessor, IConfiguration configuration) - { - _httpContextAccessor = httpContextAccessor; - _configuration = configuration; - } - public void SetTenantName(string tenantName) { _forcedTenantName = tenantName; } - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + protected override void OnConfiguring(DbContextOptionsBuilder builder) { string connectionString = GetConnectionString(); - optionsBuilder.UseNpgsql(connectionString); + builder.UseNpgsql(connectionString); } private string GetConnectionString() { string? tenantName = GetTenantName(); - string connectionString = _configuration[$"Data:{tenantName ?? "Default"}Connection"]; + string? connectionString = _configuration.GetConnectionString(tenantName ?? "Default"); if (connectionString == null) { throw GetErrorForInvalidTenant(tenantName); } - string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - return connectionString.Replace("###", postgresPassword); + return connectionString; } private string? GetTenantName() diff --git a/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj b/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj index b243e99ec2..3edc993428 100644 --- a/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj +++ b/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj @@ -1,8 +1,10 @@ - $(TargetFrameworkName) + net9.0;net8.0 + + - - + + diff --git a/src/Examples/DatabasePerTenantExample/Program.cs b/src/Examples/DatabasePerTenantExample/Program.cs index b6f960831d..4b88357d78 100644 --- a/src/Examples/DatabasePerTenantExample/Program.cs +++ b/src/Examples/DatabasePerTenantExample/Program.cs @@ -1,20 +1,29 @@ +using System.Diagnostics; using DatabasePerTenantExample.Data; using DatabasePerTenantExample.Models; using JsonApiDotNetCore.Configuration; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddSingleton(); -builder.Services.AddDbContext(options => options.UseNpgsql()); + +builder.Services.AddDbContext(options => SetDbContextDebugOptions(options)); builder.Services.AddJsonApi(options => { options.Namespace = "api"; options.UseRelativeLinks = true; + options.IncludeTotalResourceCount = true; + +#if DEBUG + options.IncludeExceptionStackTraceInErrors = true; + options.IncludeRequestBodyInErrors = true; options.SerializerOptions.WriteIndented = true; +#endif }); WebApplication app = builder.Build(); @@ -29,7 +38,15 @@ await CreateDatabaseAsync("AdventureWorks", app.Services); await CreateDatabaseAsync("Contoso", app.Services); -app.Run(); +await app.RunAsync(); + +[Conditional("DEBUG")] +static void SetDbContextDebugOptions(DbContextOptionsBuilder options) +{ + options.EnableDetailedErrors(); + options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(builder => builder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); +} static async Task CreateDatabaseAsync(string? tenantName, IServiceProvider serviceProvider) { @@ -41,18 +58,18 @@ static async Task CreateDatabaseAsync(string? tenantName, IServiceProvider servi dbContext.SetTenantName(tenantName); } - await dbContext.Database.EnsureDeletedAsync(); - await dbContext.Database.EnsureCreatedAsync(); - - if (tenantName != null) + if (await dbContext.Database.EnsureCreatedAsync()) { - dbContext.Employees.Add(new Employee + if (tenantName != null) { - FirstName = "John", - LastName = "Doe", - CompanyName = tenantName - }); + dbContext.Employees.Add(new Employee + { + FirstName = "John", + LastName = "Doe", + CompanyName = tenantName + }); - await dbContext.SaveChangesAsync(); + await dbContext.SaveChangesAsync(); + } } } diff --git a/src/Examples/DatabasePerTenantExample/Properties/launchSettings.json b/src/Examples/DatabasePerTenantExample/Properties/launchSettings.json index 1ab75296f7..43ae84e51e 100644 --- a/src/Examples/DatabasePerTenantExample/Properties/launchSettings.json +++ b/src/Examples/DatabasePerTenantExample/Properties/launchSettings.json @@ -1,5 +1,5 @@ { - "$schema": "http://json.schemastore.org/launchsettings.json", + "$schema": "https://json.schemastore.org/launchsettings.json", "iisSettings": { "windowsAuthentication": false, "anonymousAuthentication": true, diff --git a/src/Examples/DatabasePerTenantExample/appsettings.json b/src/Examples/DatabasePerTenantExample/appsettings.json index c065f66c64..1b5a40da62 100644 --- a/src/Examples/DatabasePerTenantExample/appsettings.json +++ b/src/Examples/DatabasePerTenantExample/appsettings.json @@ -1,13 +1,15 @@ { - "Data": { - "DefaultConnection": "Host=localhost;Port=5432;Database=DefaultTenantDb;User ID=postgres;Password=###", - "AdventureWorksConnection": "Host=localhost;Port=5432;Database=AdventureWorks;User ID=postgres;Password=###", - "ContosoConnection": "Host=localhost;Port=5432;Database=Contoso;User ID=postgres;Password=###" + "ConnectionStrings": { + "Default": "Host=localhost;Database=DefaultTenantDb;User ID=postgres;Password=postgres;Include Error Detail=true", + "AdventureWorks": "Host=localhost;Database=AdventureWorks;User ID=postgres;Password=postgres;Include Error Detail=true", + "Contoso": "Host=localhost;Database=Contoso;User ID=postgres;Password=postgres;Include Error Detail=true" }, "Logging": { "LogLevel": { "Default": "Warning", + // Include server startup, incoming requests and SQL commands. "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore.Hosting.Diagnostics": "Information", "Microsoft.EntityFrameworkCore.Database.Command": "Information" } }, diff --git a/src/Examples/GettingStarted/Data/SampleDbContext.cs b/src/Examples/GettingStarted/Data/SampleDbContext.cs index 44662c388b..cd8b16515d 100644 --- a/src/Examples/GettingStarted/Data/SampleDbContext.cs +++ b/src/Examples/GettingStarted/Data/SampleDbContext.cs @@ -5,12 +5,8 @@ namespace GettingStarted.Data; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public class SampleDbContext : DbContext +public class SampleDbContext(DbContextOptions options) + : DbContext(options) { public DbSet Books => Set(); - - public SampleDbContext(DbContextOptions options) - : base(options) - { - } } diff --git a/src/Examples/GettingStarted/GettingStarted.csproj b/src/Examples/GettingStarted/GettingStarted.csproj index ab152b79d5..611aeb37a5 100644 --- a/src/Examples/GettingStarted/GettingStarted.csproj +++ b/src/Examples/GettingStarted/GettingStarted.csproj @@ -1,8 +1,10 @@ - $(TargetFrameworkName) + net9.0;net8.0 + + - + diff --git a/src/Examples/GettingStarted/Program.cs b/src/Examples/GettingStarted/Program.cs index cf19380c6c..634e130a3f 100644 --- a/src/Examples/GettingStarted/Program.cs +++ b/src/Examples/GettingStarted/Program.cs @@ -1,19 +1,31 @@ +using System.Diagnostics; using GettingStarted.Data; using GettingStarted.Models; using JsonApiDotNetCore.Configuration; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); // Add services to the container. -builder.Services.AddSqlite("Data Source=sample.db;Pooling=False"); +builder.Services.AddDbContext(options => +{ + options.UseSqlite("Data Source=SampleDb.db;Pooling=False"); + SetDbContextDebugOptions(options); +}); builder.Services.AddJsonApi(options => { options.Namespace = "api"; options.UseRelativeLinks = true; options.IncludeTotalResourceCount = true; + +#if DEBUG + options.IncludeExceptionStackTraceInErrors = true; + options.IncludeRequestBodyInErrors = true; options.SerializerOptions.WriteIndented = true; +#endif }); WebApplication app = builder.Build(); @@ -26,7 +38,15 @@ await CreateDatabaseAsync(app.Services); -app.Run(); +await app.RunAsync(); + +[Conditional("DEBUG")] +static void SetDbContextDebugOptions(DbContextOptionsBuilder options) +{ + options.EnableDetailedErrors(); + options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(builder => builder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); +} static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) { diff --git a/src/Examples/GettingStarted/Properties/launchSettings.json b/src/Examples/GettingStarted/Properties/launchSettings.json index bcf154605c..304c377082 100644 --- a/src/Examples/GettingStarted/Properties/launchSettings.json +++ b/src/Examples/GettingStarted/Properties/launchSettings.json @@ -1,5 +1,5 @@ { - "$schema": "http://json.schemastore.org/launchsettings.json", + "$schema": "https://json.schemastore.org/launchsettings.json", "iisSettings": { "windowsAuthentication": false, "anonymousAuthentication": true, @@ -11,7 +11,7 @@ "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, - "launchUrl": "api/people", + "launchUrl": "api/people?include=books", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -19,7 +19,7 @@ "Kestrel": { "commandName": "Project", "launchBrowser": true, - "launchUrl": "api/people", + "launchUrl": "api/people?include=books", "applicationUrl": "http://localhost:14141", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/src/Examples/GettingStarted/README.md b/src/Examples/GettingStarted/README.md index 563899a827..4d94ffd825 100644 --- a/src/Examples/GettingStarted/README.md +++ b/src/Examples/GettingStarted/README.md @@ -7,8 +7,7 @@ You can verify the project is running by checking this endpoint: `localhost:14141/api/people` -For further documentation and implementation of a JsonApiDotNetCore Application see the documentation or GitHub page: +For further documentation and implementation of a JsonApiDotNetCore application, see the documentation or GitHub page: Repository: https://github.com/json-api-dotnet/JsonApiDotNetCore - -Documentation: http://www.jsonapi.net +Documentation: https://www.jsonapi.net diff --git a/src/Examples/GettingStarted/appsettings.json b/src/Examples/GettingStarted/appsettings.json index 270cabc088..590851ee61 100644 --- a/src/Examples/GettingStarted/appsettings.json +++ b/src/Examples/GettingStarted/appsettings.json @@ -2,7 +2,9 @@ "Logging": { "LogLevel": { "Default": "Warning", + // Include server startup, incoming requests and SQL commands. "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore.Hosting.Diagnostics": "Information", "Microsoft.EntityFrameworkCore.Database.Command": "Information" } }, diff --git a/src/Examples/JsonApiDotNetCoreExample/AppLog.cs b/src/Examples/JsonApiDotNetCoreExample/AppLog.cs new file mode 100644 index 0000000000..6cb4af1a55 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/AppLog.cs @@ -0,0 +1,9 @@ +#pragma warning disable AV1008 // Class should not be static + +namespace JsonApiDotNetCoreExample; + +internal static partial class AppLog +{ + [LoggerMessage(Level = LogLevel.Information, SkipEnabledCheck = true, Message = "Measurement results for application startup:{LineBreak}{TimingResults}")] + public static partial void LogStartupTimings(ILogger logger, string lineBreak, string timingResults); +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/NonJsonApiController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/NonJsonApiController.cs index be5e01b7a9..aa51110869 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/NonJsonApiController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/NonJsonApiController.cs @@ -8,10 +8,7 @@ public sealed class NonJsonApiController : ControllerBase [HttpGet] public IActionResult Get() { - string[] result = - { - "Welcome!" - }; + string[] result = ["Welcome!"]; return Ok(result); } @@ -19,7 +16,8 @@ public IActionResult Get() [HttpPost] public async Task PostAsync() { - string name = await new StreamReader(Request.Body).ReadToEndAsync(); + using var reader = new StreamReader(Request.Body, leaveOpen: true); + string name = await reader.ReadToEndAsync(); if (string.IsNullOrEmpty(name)) { diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/OperationsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/OperationsController.cs index 6dd2bcb9ba..a5cb2ef2e3 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/OperationsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/OperationsController.cs @@ -6,11 +6,7 @@ namespace JsonApiDotNetCoreExample.Controllers; -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 sealed class OperationsController( + IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request, + ITargetedFields targetedFields, IAtomicOperationFilter operationFilter) + : JsonApiOperationsController(options, resourceGraph, loggerFactory, processor, request, targetedFields, operationFilter); diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs index 24378e3182..f5c7e8e401 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -1,31 +1,47 @@ using JetBrains.Annotations; using JsonApiDotNetCoreExample.Models; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; // @formatter:wrap_chained_method_calls chop_always namespace JsonApiDotNetCoreExample.Data; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class AppDbContext : DbContext +public sealed class AppDbContext(DbContextOptions options) + : DbContext(options) { public DbSet TodoItems => Set(); - public AppDbContext(DbContextOptions options) - : base(options) - { - } - protected override void OnModelCreating(ModelBuilder builder) { - // When deleting a person, un-assign him/her from existing todo items. + // When deleting a person, un-assign him/her from existing todo-items. builder.Entity() .HasMany(person => person.AssignedTodoItems) - .WithOne(todoItem => todoItem.Assignee!); + .WithOne(todoItem => todoItem.Assignee); + + // When deleting a person, the todo-items he/she owns are deleted too. + builder.Entity() + .HasMany(person => person.OwnedTodoItems) + .WithOne(todoItem => todoItem.Owner); + + AdjustDeleteBehaviorForJsonApi(builder); + } + + private static void AdjustDeleteBehaviorForJsonApi(ModelBuilder builder) + { + foreach (IMutableForeignKey foreignKey in builder.Model.GetEntityTypes() + .SelectMany(entityType => entityType.GetForeignKeys())) + { + if (foreignKey.DeleteBehavior == DeleteBehavior.ClientSetNull) + { + foreignKey.DeleteBehavior = DeleteBehavior.SetNull; + } - // When deleting a person, the todo items he/she owns are deleted too. - builder.Entity() - .HasOne(todoItem => todoItem.Owner) - .WithMany(); + if (foreignKey.DeleteBehavior == DeleteBehavior.ClientCascade) + { + foreignKey.DeleteBehavior = DeleteBehavior.Cascade; + } + } } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/RotatingList.cs b/src/Examples/JsonApiDotNetCoreExample/Data/RotatingList.cs new file mode 100644 index 0000000000..c59ea96918 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Data/RotatingList.cs @@ -0,0 +1,30 @@ +namespace JsonApiDotNetCoreExample.Data; + +internal abstract class RotatingList +{ + public static RotatingList Create(int count, Func createElement) + { + List elements = []; + + for (int index = 0; index < count; index++) + { + T element = createElement(index); + elements.Add(element); + } + + return new RotatingList(elements); + } +} + +internal sealed class RotatingList(IList elements) +{ + private int _index = -1; + + public IList Elements { get; } = elements; + + public T GetNext() + { + _index++; + return Elements[_index % Elements.Count]; + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/Seeder.cs b/src/Examples/JsonApiDotNetCoreExample/Data/Seeder.cs new file mode 100644 index 0000000000..3bc2e4bacf --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/Data/Seeder.cs @@ -0,0 +1,56 @@ +using JetBrains.Annotations; +using JsonApiDotNetCoreExample.Models; + +namespace JsonApiDotNetCoreExample.Data; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +internal sealed class Seeder +{ + public static async Task CreateSampleDataAsync(AppDbContext dbContext) + { + const int todoItemCount = 500; + const int personCount = 50; + const int tagCount = 25; + + RotatingList people = RotatingList.Create(personCount, index => new Person + { + FirstName = $"FirstName{index + 1:D2}", + LastName = $"LastName{index + 1:D2}" + }); + + RotatingList tags = RotatingList.Create(tagCount, index => new Tag + { + Name = $"TagName{index + 1:D2}" + }); + + RotatingList priorities = RotatingList.Create(3, index => (TodoItemPriority)(index + 1)); + + RotatingList todoItems = RotatingList.Create(todoItemCount, index => + { + var todoItem = new TodoItem + { + Description = $"TodoItem{index + 1:D3}", + Priority = priorities.GetNext(), + DurationInHours = index, + CreatedAt = DateTimeOffset.UtcNow, + Owner = people.GetNext(), + Tags = new HashSet + { + tags.GetNext(), + tags.GetNext(), + tags.GetNext() + } + }; + + if (index % 3 == 0) + { + todoItem.Assignee = people.GetNext(); + } + + return todoItem; + }); + + dbContext.TodoItems.AddRange(todoItems.Elements); + await dbContext.SaveChangesAsync(); + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs b/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs index ee7b874fc4..06036968d0 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs @@ -5,20 +5,14 @@ using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore.Authentication; namespace JsonApiDotNetCoreExample.Definitions; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class TodoItemDefinition : JsonApiResourceDefinition +public sealed class TodoItemDefinition(IResourceGraph resourceGraph, TimeProvider timeProvider) + : JsonApiResourceDefinition(resourceGraph) { - private readonly ISystemClock _systemClock; - - public TodoItemDefinition(IResourceGraph resourceGraph, ISystemClock systemClock) - : base(resourceGraph) - { - _systemClock = systemClock; - } + private readonly TimeProvider _timeProvider = timeProvider; public override SortExpression OnApplySort(SortExpression? existingSort) { @@ -27,22 +21,21 @@ public override SortExpression OnApplySort(SortExpression? existingSort) private SortExpression GetDefaultSortOrder() { - return CreateSortExpressionFromLambda(new PropertySortOrder - { - (todoItem => todoItem.Priority, ListSortDirection.Descending), + return CreateSortExpressionFromLambda([ + (todoItem => todoItem.Priority, ListSortDirection.Ascending), (todoItem => todoItem.LastModifiedAt, ListSortDirection.Descending) - }); + ]); } public override Task OnWritingAsync(TodoItem resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) { if (writeOperation == WriteOperationKind.CreateResource) { - resource.CreatedAt = _systemClock.UtcNow; + resource.CreatedAt = _timeProvider.GetUtcNow(); } else if (writeOperation == WriteOperationKind.UpdateResource) { - resource.LastModifiedAt = _systemClock.UtcNow; + resource.LastModifiedAt = _timeProvider.GetUtcNow(); } return Task.CompletedTask; diff --git a/src/Examples/JsonApiDotNetCoreExample/GeneratedSwagger/JsonApiDotNetCoreExample.json b/src/Examples/JsonApiDotNetCoreExample/GeneratedSwagger/JsonApiDotNetCoreExample.json new file mode 100644 index 0000000000..4863000598 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/GeneratedSwagger/JsonApiDotNetCoreExample.json @@ -0,0 +1,8583 @@ +{ + "openapi": "3.0.4", + "info": { + "title": "JsonApiDotNetCoreExample", + "version": "1.0" + }, + "servers": [ + { + "url": "https://localhost:44340" + } + ], + "paths": { + "/api/operations": { + "post": { + "tags": [ + "operations" + ], + "summary": "Performs multiple mutations in a linear and atomic manner.", + "operationId": "postOperations", + "requestBody": { + "description": "An array of mutation operations. For syntax, see the [Atomic Operations documentation](https://jsonapi.org/ext/atomic/).", + "content": { + "application/vnd.api+json; ext=atomic; ext=openapi": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/operationsRequestDocument" + } + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "All operations were successfully applied, which resulted in additional changes.", + "content": { + "application/vnd.api+json; ext=atomic; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/operationsResponseDocument" + } + } + } + }, + "204": { + "description": "All operations were successfully applied, which did not result in additional changes." + }, + "400": { + "description": "The request body is missing or malformed.", + "content": { + "application/vnd.api+json; ext=atomic; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "403": { + "description": "An operation is not accessible or a client-generated ID is used.", + "content": { + "application/vnd.api+json; ext=atomic; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "A resource or a related resource does not exist.", + "content": { + "application/vnd.api+json; ext=atomic; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "409": { + "description": "The request body contains conflicting information or another resource with the same ID already exists.", + "content": { + "application/vnd.api+json; ext=atomic; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "422": { + "description": "Validation of the request body failed.", + "content": { + "application/vnd.api+json; ext=atomic; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + } + }, + "/api/people": { + "get": { + "tags": [ + "people" + ], + "summary": "Retrieves a collection of people.", + "operationId": "getPersonCollection", + "parameters": [ + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successfully returns the found people, or an empty array if none were found.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + }, + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/personCollectionResponseDocument" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "head": { + "tags": [ + "people" + ], + "summary": "Retrieves a collection of people without returning them.", + "description": "Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched.", + "operationId": "headPersonCollection", + "parameters": [ + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The operation completed successfully.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + }, + "Content-Length": { + "description": "Size of the HTTP response body, in bytes.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid." + } + } + }, + "post": { + "tags": [ + "people" + ], + "summary": "Creates a new person.", + "operationId": "postPerson", + "parameters": [ + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + } + ], + "requestBody": { + "description": "The attributes and relationships of the person to create.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/createPersonRequestDocument" + } + ] + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "The person was successfully created, which resulted in additional changes. The newly created person is returned.", + "headers": { + "Location": { + "description": "The URL at which the newly created person can be retrieved.", + "required": true, + "schema": { + "type": "string", + "format": "uri" + } + } + }, + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/primaryPersonResponseDocument" + } + } + } + }, + "204": { + "description": "The person was successfully created, which did not result in additional changes." + }, + "400": { + "description": "The query string is invalid or the request body is missing or malformed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "403": { + "description": "Client-generated IDs cannot be used at this endpoint.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "A related resource does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "409": { + "description": "The request body contains conflicting information or another resource with the same ID already exists.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "422": { + "description": "Validation of the request body failed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + } + }, + "/api/people/{id}": { + "get": { + "tags": [ + "people" + ], + "summary": "Retrieves an individual person by its identifier.", + "operationId": "getPerson", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the person to retrieve.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successfully returns the found person.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + }, + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/primaryPersonResponseDocument" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The person does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "head": { + "tags": [ + "people" + ], + "summary": "Retrieves an individual person by its identifier without returning it.", + "description": "Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched.", + "operationId": "headPerson", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the person to retrieve.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The operation completed successfully.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + }, + "Content-Length": { + "description": "Size of the HTTP response body, in bytes.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid." + }, + "404": { + "description": "The person does not exist." + } + } + }, + "patch": { + "tags": [ + "people" + ], + "summary": "Updates an existing person.", + "operationId": "patchPerson", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the person to update.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + } + ], + "requestBody": { + "description": "The attributes and relationships of the person to update. Omitted fields are left unchanged.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/updatePersonRequestDocument" + } + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "The person was successfully updated, which resulted in additional changes. The updated person is returned.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/primaryPersonResponseDocument" + } + } + } + }, + "204": { + "description": "The person was successfully updated, which did not result in additional changes." + }, + "400": { + "description": "The query string is invalid or the request body is missing or malformed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The person or a related resource does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "409": { + "description": "A resource type or identifier in the request body is incompatible.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "422": { + "description": "Validation of the request body failed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "delete": { + "tags": [ + "people" + ], + "summary": "Deletes an existing person by its identifier.", + "operationId": "deletePerson", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the person to delete.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + } + ], + "responses": { + "204": { + "description": "The person was successfully deleted." + }, + "404": { + "description": "The person does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + } + }, + "/api/people/{id}/assignedTodoItems": { + "get": { + "tags": [ + "people" + ], + "summary": "Retrieves the related todoItems of an individual person's assignedTodoItems relationship.", + "operationId": "getPersonAssignedTodoItems", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the person whose related todoItems to retrieve.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successfully returns the found todoItems, or an empty array if none were found.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + }, + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/todoItemCollectionResponseDocument" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The person does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "head": { + "tags": [ + "people" + ], + "summary": "Retrieves the related todoItems of an individual person's assignedTodoItems relationship without returning them.", + "description": "Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched.", + "operationId": "headPersonAssignedTodoItems", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the person whose related todoItems to retrieve.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The operation completed successfully.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + }, + "Content-Length": { + "description": "Size of the HTTP response body, in bytes.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid." + }, + "404": { + "description": "The person does not exist." + } + } + } + }, + "/api/people/{id}/relationships/assignedTodoItems": { + "get": { + "tags": [ + "people" + ], + "summary": "Retrieves the related todoItem identities of an individual person's assignedTodoItems relationship.", + "operationId": "getPersonAssignedTodoItemsRelationship", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the person whose related todoItem identities to retrieve.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successfully returns the found todoItem identities, or an empty array if none were found.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + }, + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/todoItemIdentifierCollectionResponseDocument" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The person does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "head": { + "tags": [ + "people" + ], + "summary": "Retrieves the related todoItem identities of an individual person's assignedTodoItems relationship without returning them.", + "description": "Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched.", + "operationId": "headPersonAssignedTodoItemsRelationship", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the person whose related todoItem identities to retrieve.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The operation completed successfully.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + }, + "Content-Length": { + "description": "Size of the HTTP response body, in bytes.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid." + }, + "404": { + "description": "The person does not exist." + } + } + }, + "post": { + "tags": [ + "people" + ], + "summary": "Adds existing todoItems to the assignedTodoItems relationship of an individual person.", + "operationId": "postPersonAssignedTodoItemsRelationship", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the person to add todoItems to.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + } + ], + "requestBody": { + "description": "The identities of the todoItems to add to the assignedTodoItems relationship.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/toManyTodoItemInRequest" + } + ] + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "The todoItems were successfully added, which did not result in additional changes." + }, + "400": { + "description": "The request body is missing or malformed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The person or a related resource does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "409": { + "description": "The request body contains conflicting information or another resource with the same ID already exists.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "422": { + "description": "Validation of the request body failed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "patch": { + "tags": [ + "people" + ], + "summary": "Assigns existing todoItems to the assignedTodoItems relationship of an individual person.", + "operationId": "patchPersonAssignedTodoItemsRelationship", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the person whose assignedTodoItems relationship to assign.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + } + ], + "requestBody": { + "description": "The identities of the todoItems to assign to the assignedTodoItems relationship, or an empty array to clear the relationship.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/toManyTodoItemInRequest" + } + ] + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "The assignedTodoItems relationship was successfully updated, which did not result in additional changes." + }, + "400": { + "description": "The request body is missing or malformed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The person or a related resource does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "409": { + "description": "The request body contains conflicting information or another resource with the same ID already exists.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "422": { + "description": "Validation of the request body failed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "delete": { + "tags": [ + "people" + ], + "summary": "Removes existing todoItems from the assignedTodoItems relationship of an individual person.", + "operationId": "deletePersonAssignedTodoItemsRelationship", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the person to remove todoItems from.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + } + ], + "requestBody": { + "description": "The identities of the todoItems to remove from the assignedTodoItems relationship.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/toManyTodoItemInRequest" + } + ] + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "The todoItems were successfully removed, which did not result in additional changes." + }, + "400": { + "description": "The request body is missing or malformed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The person or a related resource does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "409": { + "description": "The request body contains conflicting information or another resource with the same ID already exists.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "422": { + "description": "Validation of the request body failed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + } + }, + "/api/people/{id}/ownedTodoItems": { + "get": { + "tags": [ + "people" + ], + "summary": "Retrieves the related todoItems of an individual person's ownedTodoItems relationship.", + "operationId": "getPersonOwnedTodoItems", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the person whose related todoItems to retrieve.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successfully returns the found todoItems, or an empty array if none were found.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + }, + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/todoItemCollectionResponseDocument" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The person does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "head": { + "tags": [ + "people" + ], + "summary": "Retrieves the related todoItems of an individual person's ownedTodoItems relationship without returning them.", + "description": "Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched.", + "operationId": "headPersonOwnedTodoItems", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the person whose related todoItems to retrieve.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The operation completed successfully.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + }, + "Content-Length": { + "description": "Size of the HTTP response body, in bytes.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid." + }, + "404": { + "description": "The person does not exist." + } + } + } + }, + "/api/people/{id}/relationships/ownedTodoItems": { + "get": { + "tags": [ + "people" + ], + "summary": "Retrieves the related todoItem identities of an individual person's ownedTodoItems relationship.", + "operationId": "getPersonOwnedTodoItemsRelationship", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the person whose related todoItem identities to retrieve.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successfully returns the found todoItem identities, or an empty array if none were found.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + }, + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/todoItemIdentifierCollectionResponseDocument" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The person does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "head": { + "tags": [ + "people" + ], + "summary": "Retrieves the related todoItem identities of an individual person's ownedTodoItems relationship without returning them.", + "description": "Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched.", + "operationId": "headPersonOwnedTodoItemsRelationship", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the person whose related todoItem identities to retrieve.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The operation completed successfully.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + }, + "Content-Length": { + "description": "Size of the HTTP response body, in bytes.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid." + }, + "404": { + "description": "The person does not exist." + } + } + }, + "post": { + "tags": [ + "people" + ], + "summary": "Adds existing todoItems to the ownedTodoItems relationship of an individual person.", + "operationId": "postPersonOwnedTodoItemsRelationship", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the person to add todoItems to.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + } + ], + "requestBody": { + "description": "The identities of the todoItems to add to the ownedTodoItems relationship.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/toManyTodoItemInRequest" + } + ] + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "The todoItems were successfully added, which did not result in additional changes." + }, + "400": { + "description": "The request body is missing or malformed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The person or a related resource does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "409": { + "description": "The request body contains conflicting information or another resource with the same ID already exists.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "422": { + "description": "Validation of the request body failed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "patch": { + "tags": [ + "people" + ], + "summary": "Assigns existing todoItems to the ownedTodoItems relationship of an individual person.", + "operationId": "patchPersonOwnedTodoItemsRelationship", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the person whose ownedTodoItems relationship to assign.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + } + ], + "requestBody": { + "description": "The identities of the todoItems to assign to the ownedTodoItems relationship, or an empty array to clear the relationship.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/toManyTodoItemInRequest" + } + ] + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "The ownedTodoItems relationship was successfully updated, which did not result in additional changes." + }, + "400": { + "description": "The request body is missing or malformed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The person or a related resource does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "409": { + "description": "The request body contains conflicting information or another resource with the same ID already exists.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "422": { + "description": "Validation of the request body failed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "delete": { + "tags": [ + "people" + ], + "summary": "Removes existing todoItems from the ownedTodoItems relationship of an individual person.", + "operationId": "deletePersonOwnedTodoItemsRelationship", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the person to remove todoItems from.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + } + ], + "requestBody": { + "description": "The identities of the todoItems to remove from the ownedTodoItems relationship.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/toManyTodoItemInRequest" + } + ] + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "The todoItems were successfully removed, which did not result in additional changes." + }, + "400": { + "description": "The request body is missing or malformed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The person or a related resource does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "409": { + "description": "The request body contains conflicting information or another resource with the same ID already exists.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "422": { + "description": "Validation of the request body failed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + } + }, + "/api/tags": { + "get": { + "tags": [ + "tags" + ], + "summary": "Retrieves a collection of tags.", + "operationId": "getTagCollection", + "parameters": [ + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successfully returns the found tags, or an empty array if none were found.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + }, + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/tagCollectionResponseDocument" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "head": { + "tags": [ + "tags" + ], + "summary": "Retrieves a collection of tags without returning them.", + "description": "Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched.", + "operationId": "headTagCollection", + "parameters": [ + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The operation completed successfully.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + }, + "Content-Length": { + "description": "Size of the HTTP response body, in bytes.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid." + } + } + }, + "post": { + "tags": [ + "tags" + ], + "summary": "Creates a new tag.", + "operationId": "postTag", + "parameters": [ + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + } + ], + "requestBody": { + "description": "The attributes and relationships of the tag to create.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/createTagRequestDocument" + } + ] + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "The tag was successfully created, which resulted in additional changes. The newly created tag is returned.", + "headers": { + "Location": { + "description": "The URL at which the newly created tag can be retrieved.", + "required": true, + "schema": { + "type": "string", + "format": "uri" + } + } + }, + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/primaryTagResponseDocument" + } + } + } + }, + "204": { + "description": "The tag was successfully created, which did not result in additional changes." + }, + "400": { + "description": "The query string is invalid or the request body is missing or malformed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "403": { + "description": "Client-generated IDs cannot be used at this endpoint.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "A related resource does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "409": { + "description": "The request body contains conflicting information or another resource with the same ID already exists.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "422": { + "description": "Validation of the request body failed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + } + }, + "/api/tags/{id}": { + "get": { + "tags": [ + "tags" + ], + "summary": "Retrieves an individual tag by its identifier.", + "operationId": "getTag", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the tag to retrieve.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successfully returns the found tag.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + }, + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/primaryTagResponseDocument" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The tag does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "head": { + "tags": [ + "tags" + ], + "summary": "Retrieves an individual tag by its identifier without returning it.", + "description": "Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched.", + "operationId": "headTag", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the tag to retrieve.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The operation completed successfully.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + }, + "Content-Length": { + "description": "Size of the HTTP response body, in bytes.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid." + }, + "404": { + "description": "The tag does not exist." + } + } + }, + "patch": { + "tags": [ + "tags" + ], + "summary": "Updates an existing tag.", + "operationId": "patchTag", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the tag to update.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + } + ], + "requestBody": { + "description": "The attributes and relationships of the tag to update. Omitted fields are left unchanged.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/updateTagRequestDocument" + } + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "The tag was successfully updated, which resulted in additional changes. The updated tag is returned.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/primaryTagResponseDocument" + } + } + } + }, + "204": { + "description": "The tag was successfully updated, which did not result in additional changes." + }, + "400": { + "description": "The query string is invalid or the request body is missing or malformed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The tag or a related resource does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "409": { + "description": "A resource type or identifier in the request body is incompatible.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "422": { + "description": "Validation of the request body failed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "delete": { + "tags": [ + "tags" + ], + "summary": "Deletes an existing tag by its identifier.", + "operationId": "deleteTag", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the tag to delete.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + } + ], + "responses": { + "204": { + "description": "The tag was successfully deleted." + }, + "404": { + "description": "The tag does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + } + }, + "/api/tags/{id}/todoItems": { + "get": { + "tags": [ + "tags" + ], + "summary": "Retrieves the related todoItems of an individual tag's todoItems relationship.", + "operationId": "getTagTodoItems", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the tag whose related todoItems to retrieve.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successfully returns the found todoItems, or an empty array if none were found.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + }, + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/todoItemCollectionResponseDocument" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The tag does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "head": { + "tags": [ + "tags" + ], + "summary": "Retrieves the related todoItems of an individual tag's todoItems relationship without returning them.", + "description": "Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched.", + "operationId": "headTagTodoItems", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the tag whose related todoItems to retrieve.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The operation completed successfully.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + }, + "Content-Length": { + "description": "Size of the HTTP response body, in bytes.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid." + }, + "404": { + "description": "The tag does not exist." + } + } + } + }, + "/api/tags/{id}/relationships/todoItems": { + "get": { + "tags": [ + "tags" + ], + "summary": "Retrieves the related todoItem identities of an individual tag's todoItems relationship.", + "operationId": "getTagTodoItemsRelationship", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the tag whose related todoItem identities to retrieve.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successfully returns the found todoItem identities, or an empty array if none were found.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + }, + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/todoItemIdentifierCollectionResponseDocument" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The tag does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "head": { + "tags": [ + "tags" + ], + "summary": "Retrieves the related todoItem identities of an individual tag's todoItems relationship without returning them.", + "description": "Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched.", + "operationId": "headTagTodoItemsRelationship", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the tag whose related todoItem identities to retrieve.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The operation completed successfully.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + }, + "Content-Length": { + "description": "Size of the HTTP response body, in bytes.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid." + }, + "404": { + "description": "The tag does not exist." + } + } + }, + "post": { + "tags": [ + "tags" + ], + "summary": "Adds existing todoItems to the todoItems relationship of an individual tag.", + "operationId": "postTagTodoItemsRelationship", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the tag to add todoItems to.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + } + ], + "requestBody": { + "description": "The identities of the todoItems to add to the todoItems relationship.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/toManyTodoItemInRequest" + } + ] + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "The todoItems were successfully added, which did not result in additional changes." + }, + "400": { + "description": "The request body is missing or malformed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The tag or a related resource does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "409": { + "description": "The request body contains conflicting information or another resource with the same ID already exists.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "422": { + "description": "Validation of the request body failed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "patch": { + "tags": [ + "tags" + ], + "summary": "Assigns existing todoItems to the todoItems relationship of an individual tag.", + "operationId": "patchTagTodoItemsRelationship", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the tag whose todoItems relationship to assign.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + } + ], + "requestBody": { + "description": "The identities of the todoItems to assign to the todoItems relationship, or an empty array to clear the relationship.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/toManyTodoItemInRequest" + } + ] + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "The todoItems relationship was successfully updated, which did not result in additional changes." + }, + "400": { + "description": "The request body is missing or malformed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The tag or a related resource does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "409": { + "description": "The request body contains conflicting information or another resource with the same ID already exists.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "422": { + "description": "Validation of the request body failed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "delete": { + "tags": [ + "tags" + ], + "summary": "Removes existing todoItems from the todoItems relationship of an individual tag.", + "operationId": "deleteTagTodoItemsRelationship", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the tag to remove todoItems from.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + } + ], + "requestBody": { + "description": "The identities of the todoItems to remove from the todoItems relationship.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/toManyTodoItemInRequest" + } + ] + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "The todoItems were successfully removed, which did not result in additional changes." + }, + "400": { + "description": "The request body is missing or malformed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The tag or a related resource does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "409": { + "description": "The request body contains conflicting information or another resource with the same ID already exists.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "422": { + "description": "Validation of the request body failed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + } + }, + "/api/todoItems": { + "get": { + "tags": [ + "todoItems" + ], + "summary": "Retrieves a collection of todoItems.", + "operationId": "getTodoItemCollection", + "parameters": [ + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successfully returns the found todoItems, or an empty array if none were found.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + }, + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/todoItemCollectionResponseDocument" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "head": { + "tags": [ + "todoItems" + ], + "summary": "Retrieves a collection of todoItems without returning them.", + "description": "Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched.", + "operationId": "headTodoItemCollection", + "parameters": [ + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The operation completed successfully.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + }, + "Content-Length": { + "description": "Size of the HTTP response body, in bytes.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid." + } + } + }, + "post": { + "tags": [ + "todoItems" + ], + "summary": "Creates a new todoItem.", + "operationId": "postTodoItem", + "parameters": [ + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + } + ], + "requestBody": { + "description": "The attributes and relationships of the todoItem to create.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/createTodoItemRequestDocument" + } + ] + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "The todoItem was successfully created, which resulted in additional changes. The newly created todoItem is returned.", + "headers": { + "Location": { + "description": "The URL at which the newly created todoItem can be retrieved.", + "required": true, + "schema": { + "type": "string", + "format": "uri" + } + } + }, + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/primaryTodoItemResponseDocument" + } + } + } + }, + "204": { + "description": "The todoItem was successfully created, which did not result in additional changes." + }, + "400": { + "description": "The query string is invalid or the request body is missing or malformed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "403": { + "description": "Client-generated IDs cannot be used at this endpoint.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "A related resource does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "409": { + "description": "The request body contains conflicting information or another resource with the same ID already exists.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "422": { + "description": "Validation of the request body failed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + } + }, + "/api/todoItems/{id}": { + "get": { + "tags": [ + "todoItems" + ], + "summary": "Retrieves an individual todoItem by its identifier.", + "operationId": "getTodoItem", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the todoItem to retrieve.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successfully returns the found todoItem.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + }, + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/primaryTodoItemResponseDocument" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The todoItem does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "head": { + "tags": [ + "todoItems" + ], + "summary": "Retrieves an individual todoItem by its identifier without returning it.", + "description": "Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched.", + "operationId": "headTodoItem", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the todoItem to retrieve.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The operation completed successfully.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + }, + "Content-Length": { + "description": "Size of the HTTP response body, in bytes.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid." + }, + "404": { + "description": "The todoItem does not exist." + } + } + }, + "patch": { + "tags": [ + "todoItems" + ], + "summary": "Updates an existing todoItem.", + "operationId": "patchTodoItem", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the todoItem to update.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + } + ], + "requestBody": { + "description": "The attributes and relationships of the todoItem to update. Omitted fields are left unchanged.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/updateTodoItemRequestDocument" + } + ] + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "The todoItem was successfully updated, which resulted in additional changes. The updated todoItem is returned.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/primaryTodoItemResponseDocument" + } + } + } + }, + "204": { + "description": "The todoItem was successfully updated, which did not result in additional changes." + }, + "400": { + "description": "The query string is invalid or the request body is missing or malformed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The todoItem or a related resource does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "409": { + "description": "A resource type or identifier in the request body is incompatible.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "422": { + "description": "Validation of the request body failed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "delete": { + "tags": [ + "todoItems" + ], + "summary": "Deletes an existing todoItem by its identifier.", + "operationId": "deleteTodoItem", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the todoItem to delete.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + } + ], + "responses": { + "204": { + "description": "The todoItem was successfully deleted." + }, + "404": { + "description": "The todoItem does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + } + }, + "/api/todoItems/{id}/assignee": { + "get": { + "tags": [ + "todoItems" + ], + "summary": "Retrieves the related person of an individual todoItem's assignee relationship.", + "operationId": "getTodoItemAssignee", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the todoItem whose related person to retrieve.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successfully returns the found person, or `null` if it was not found.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + }, + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/nullableSecondaryPersonResponseDocument" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The todoItem does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "head": { + "tags": [ + "todoItems" + ], + "summary": "Retrieves the related person of an individual todoItem's assignee relationship without returning it.", + "description": "Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched.", + "operationId": "headTodoItemAssignee", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the todoItem whose related person to retrieve.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The operation completed successfully.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + }, + "Content-Length": { + "description": "Size of the HTTP response body, in bytes.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid." + }, + "404": { + "description": "The todoItem does not exist." + } + } + } + }, + "/api/todoItems/{id}/relationships/assignee": { + "get": { + "tags": [ + "todoItems" + ], + "summary": "Retrieves the related person identity of an individual todoItem's assignee relationship.", + "operationId": "getTodoItemAssigneeRelationship", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the todoItem whose related person identity to retrieve.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successfully returns the found person identity, or `null` if it was not found.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + }, + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/nullablePersonIdentifierResponseDocument" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The todoItem does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "head": { + "tags": [ + "todoItems" + ], + "summary": "Retrieves the related person identity of an individual todoItem's assignee relationship without returning it.", + "description": "Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched.", + "operationId": "headTodoItemAssigneeRelationship", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the todoItem whose related person identity to retrieve.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The operation completed successfully.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + }, + "Content-Length": { + "description": "Size of the HTTP response body, in bytes.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid." + }, + "404": { + "description": "The todoItem does not exist." + } + } + }, + "patch": { + "tags": [ + "todoItems" + ], + "summary": "Clears or assigns an existing person to the assignee relationship of an individual todoItem.", + "operationId": "patchTodoItemAssigneeRelationship", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the todoItem whose assignee relationship to assign or clear.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + } + ], + "requestBody": { + "description": "The identity of the person to assign to the assignee relationship, or `null` to clear the relationship.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/nullableToOnePersonInRequest" + } + ] + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "The assignee relationship was successfully updated, which did not result in additional changes." + }, + "400": { + "description": "The request body is missing or malformed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The todoItem or a related resource does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "409": { + "description": "The request body contains conflicting information or another resource with the same ID already exists.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "422": { + "description": "Validation of the request body failed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + } + }, + "/api/todoItems/{id}/owner": { + "get": { + "tags": [ + "todoItems" + ], + "summary": "Retrieves the related person of an individual todoItem's owner relationship.", + "operationId": "getTodoItemOwner", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the todoItem whose related person to retrieve.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successfully returns the found person, or `null` if it was not found.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + }, + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/secondaryPersonResponseDocument" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The todoItem does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "head": { + "tags": [ + "todoItems" + ], + "summary": "Retrieves the related person of an individual todoItem's owner relationship without returning it.", + "description": "Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched.", + "operationId": "headTodoItemOwner", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the todoItem whose related person to retrieve.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The operation completed successfully.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + }, + "Content-Length": { + "description": "Size of the HTTP response body, in bytes.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid." + }, + "404": { + "description": "The todoItem does not exist." + } + } + } + }, + "/api/todoItems/{id}/relationships/owner": { + "get": { + "tags": [ + "todoItems" + ], + "summary": "Retrieves the related person identity of an individual todoItem's owner relationship.", + "operationId": "getTodoItemOwnerRelationship", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the todoItem whose related person identity to retrieve.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successfully returns the found person identity, or `null` if it was not found.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + }, + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/personIdentifierResponseDocument" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The todoItem does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "head": { + "tags": [ + "todoItems" + ], + "summary": "Retrieves the related person identity of an individual todoItem's owner relationship without returning it.", + "description": "Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched.", + "operationId": "headTodoItemOwnerRelationship", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the todoItem whose related person identity to retrieve.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The operation completed successfully.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + }, + "Content-Length": { + "description": "Size of the HTTP response body, in bytes.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid." + }, + "404": { + "description": "The todoItem does not exist." + } + } + }, + "patch": { + "tags": [ + "todoItems" + ], + "summary": "Assigns an existing person to the owner relationship of an individual todoItem.", + "operationId": "patchTodoItemOwnerRelationship", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the todoItem whose owner relationship to assign.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + } + ], + "requestBody": { + "description": "The identity of the person to assign to the owner relationship.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/toOnePersonInRequest" + } + ] + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "The owner relationship was successfully updated, which did not result in additional changes." + }, + "400": { + "description": "The request body is missing or malformed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The todoItem or a related resource does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "409": { + "description": "The request body contains conflicting information or another resource with the same ID already exists.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "422": { + "description": "Validation of the request body failed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + } + }, + "/api/todoItems/{id}/tags": { + "get": { + "tags": [ + "todoItems" + ], + "summary": "Retrieves the related tags of an individual todoItem's tags relationship.", + "operationId": "getTodoItemTags", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the todoItem whose related tags to retrieve.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successfully returns the found tags, or an empty array if none were found.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + }, + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/tagCollectionResponseDocument" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The todoItem does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "head": { + "tags": [ + "todoItems" + ], + "summary": "Retrieves the related tags of an individual todoItem's tags relationship without returning them.", + "description": "Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched.", + "operationId": "headTodoItemTags", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the todoItem whose related tags to retrieve.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The operation completed successfully.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + }, + "Content-Length": { + "description": "Size of the HTTP response body, in bytes.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid." + }, + "404": { + "description": "The todoItem does not exist." + } + } + } + }, + "/api/todoItems/{id}/relationships/tags": { + "get": { + "tags": [ + "todoItems" + ], + "summary": "Retrieves the related tag identities of an individual todoItem's tags relationship.", + "operationId": "getTodoItemTagsRelationship", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the todoItem whose related tag identities to retrieve.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successfully returns the found tag identities, or an empty array if none were found.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + }, + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/tagIdentifierCollectionResponseDocument" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The todoItem does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "head": { + "tags": [ + "todoItems" + ], + "summary": "Retrieves the related tag identities of an individual todoItem's tags relationship without returning them.", + "description": "Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched.", + "operationId": "headTodoItemTagsRelationship", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the todoItem whose related tag identities to retrieve.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + }, + { + "name": "query", + "in": "query", + "description": "For syntax, see the documentation for the [`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters.", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string", + "nullable": true + }, + "example": "" + } + }, + { + "name": "If-None-Match", + "in": "header", + "description": "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The operation completed successfully.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + }, + "Content-Length": { + "description": "Size of the HTTP response body, in bytes.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + } + }, + "304": { + "description": "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header.", + "headers": { + "ETag": { + "description": "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + "required": true, + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "The query string is invalid." + }, + "404": { + "description": "The todoItem does not exist." + } + } + }, + "post": { + "tags": [ + "todoItems" + ], + "summary": "Adds existing tags to the tags relationship of an individual todoItem.", + "operationId": "postTodoItemTagsRelationship", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the todoItem to add tags to.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + } + ], + "requestBody": { + "description": "The identities of the tags to add to the tags relationship.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/toManyTagInRequest" + } + ] + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "The tags were successfully added, which did not result in additional changes." + }, + "400": { + "description": "The request body is missing or malformed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The todoItem or a related resource does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "409": { + "description": "The request body contains conflicting information or another resource with the same ID already exists.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "422": { + "description": "Validation of the request body failed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "patch": { + "tags": [ + "todoItems" + ], + "summary": "Assigns existing tags to the tags relationship of an individual todoItem.", + "operationId": "patchTodoItemTagsRelationship", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the todoItem whose tags relationship to assign.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + } + ], + "requestBody": { + "description": "The identities of the tags to assign to the tags relationship, or an empty array to clear the relationship.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/toManyTagInRequest" + } + ] + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "The tags relationship was successfully updated, which did not result in additional changes." + }, + "400": { + "description": "The request body is missing or malformed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The todoItem or a related resource does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "409": { + "description": "The request body contains conflicting information or another resource with the same ID already exists.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "422": { + "description": "Validation of the request body failed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + }, + "delete": { + "tags": [ + "todoItems" + ], + "summary": "Removes existing tags from the tags relationship of an individual todoItem.", + "operationId": "deleteTodoItemTagsRelationship", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the todoItem to remove tags from.", + "required": true, + "schema": { + "minLength": 1, + "type": "string", + "format": "int64" + } + } + ], + "requestBody": { + "description": "The identities of the tags to remove from the tags relationship.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/toManyTagInRequest" + } + ] + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "The tags were successfully removed, which did not result in additional changes." + }, + "400": { + "description": "The request body is missing or malformed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "404": { + "description": "The todoItem or a related resource does not exist.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "409": { + "description": "The request body contains conflicting information or another resource with the same ID already exists.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + }, + "422": { + "description": "Validation of the request body failed.", + "content": { + "application/vnd.api+json; ext=openapi": { + "schema": { + "$ref": "#/components/schemas/errorResponseDocument" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "addOperationCode": { + "enum": [ + "add" + ], + "type": "string" + }, + "addToPersonAssignedTodoItemsRelationshipOperation": { + "allOf": [ + { + "$ref": "#/components/schemas/atomicOperation" + }, + { + "required": [ + "data", + "op", + "ref" + ], + "type": "object", + "properties": { + "op": { + "allOf": [ + { + "$ref": "#/components/schemas/addOperationCode" + } + ] + }, + "ref": { + "allOf": [ + { + "$ref": "#/components/schemas/personAssignedTodoItemsRelationshipIdentifier" + } + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/todoItemIdentifierInRequest" + } + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "addToPersonOwnedTodoItemsRelationshipOperation": { + "allOf": [ + { + "$ref": "#/components/schemas/atomicOperation" + }, + { + "required": [ + "data", + "op", + "ref" + ], + "type": "object", + "properties": { + "op": { + "allOf": [ + { + "$ref": "#/components/schemas/addOperationCode" + } + ] + }, + "ref": { + "allOf": [ + { + "$ref": "#/components/schemas/personOwnedTodoItemsRelationshipIdentifier" + } + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/todoItemIdentifierInRequest" + } + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "addToTagTodoItemsRelationshipOperation": { + "allOf": [ + { + "$ref": "#/components/schemas/atomicOperation" + }, + { + "required": [ + "data", + "op", + "ref" + ], + "type": "object", + "properties": { + "op": { + "allOf": [ + { + "$ref": "#/components/schemas/addOperationCode" + } + ] + }, + "ref": { + "allOf": [ + { + "$ref": "#/components/schemas/tagTodoItemsRelationshipIdentifier" + } + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/todoItemIdentifierInRequest" + } + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "addToTodoItemTagsRelationshipOperation": { + "allOf": [ + { + "$ref": "#/components/schemas/atomicOperation" + }, + { + "required": [ + "data", + "op", + "ref" + ], + "type": "object", + "properties": { + "op": { + "allOf": [ + { + "$ref": "#/components/schemas/addOperationCode" + } + ] + }, + "ref": { + "allOf": [ + { + "$ref": "#/components/schemas/todoItemTagsRelationshipIdentifier" + } + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/tagIdentifierInRequest" + } + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "atomicOperation": { + "required": [ + "openapi:discriminator" + ], + "type": "object", + "properties": { + "openapi:discriminator": { + "type": "string" + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false, + "discriminator": { + "propertyName": "openapi:discriminator", + "mapping": { + "addPerson": "#/components/schemas/createPersonOperation", + "addTag": "#/components/schemas/createTagOperation", + "addToPersonAssignedTodoItems": "#/components/schemas/addToPersonAssignedTodoItemsRelationshipOperation", + "addToPersonOwnedTodoItems": "#/components/schemas/addToPersonOwnedTodoItemsRelationshipOperation", + "addToTagTodoItems": "#/components/schemas/addToTagTodoItemsRelationshipOperation", + "addToTodoItemTags": "#/components/schemas/addToTodoItemTagsRelationshipOperation", + "addTodoItem": "#/components/schemas/createTodoItemOperation", + "removeFromPersonAssignedTodoItems": "#/components/schemas/removeFromPersonAssignedTodoItemsRelationshipOperation", + "removeFromPersonOwnedTodoItems": "#/components/schemas/removeFromPersonOwnedTodoItemsRelationshipOperation", + "removeFromTagTodoItems": "#/components/schemas/removeFromTagTodoItemsRelationshipOperation", + "removeFromTodoItemTags": "#/components/schemas/removeFromTodoItemTagsRelationshipOperation", + "removePerson": "#/components/schemas/deletePersonOperation", + "removeTag": "#/components/schemas/deleteTagOperation", + "removeTodoItem": "#/components/schemas/deleteTodoItemOperation", + "updatePerson": "#/components/schemas/updatePersonOperation", + "updatePersonAssignedTodoItems": "#/components/schemas/updatePersonAssignedTodoItemsRelationshipOperation", + "updatePersonOwnedTodoItems": "#/components/schemas/updatePersonOwnedTodoItemsRelationshipOperation", + "updateTag": "#/components/schemas/updateTagOperation", + "updateTagTodoItems": "#/components/schemas/updateTagTodoItemsRelationshipOperation", + "updateTodoItem": "#/components/schemas/updateTodoItemOperation", + "updateTodoItemAssignee": "#/components/schemas/updateTodoItemAssigneeRelationshipOperation", + "updateTodoItemOwner": "#/components/schemas/updateTodoItemOwnerRelationshipOperation", + "updateTodoItemTags": "#/components/schemas/updateTodoItemTagsRelationshipOperation" + } + }, + "x-abstract": true + }, + "atomicResult": { + "type": "object", + "properties": { + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceInResponse" + } + ] + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "attributesInCreatePersonRequest": { + "allOf": [ + { + "$ref": "#/components/schemas/attributesInCreateRequest" + }, + { + "required": [ + "lastName" + ], + "type": "object", + "properties": { + "firstName": { + "type": "string", + "nullable": true + }, + "lastName": { + "type": "string" + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "attributesInCreateRequest": { + "required": [ + "openapi:discriminator" + ], + "type": "object", + "properties": { + "openapi:discriminator": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceType" + } + ] + } + }, + "additionalProperties": false, + "discriminator": { + "propertyName": "openapi:discriminator", + "mapping": { + "people": "#/components/schemas/attributesInCreatePersonRequest", + "tags": "#/components/schemas/attributesInCreateTagRequest", + "todoItems": "#/components/schemas/attributesInCreateTodoItemRequest" + } + }, + "x-abstract": true + }, + "attributesInCreateTagRequest": { + "allOf": [ + { + "$ref": "#/components/schemas/attributesInCreateRequest" + }, + { + "required": [ + "name" + ], + "type": "object", + "properties": { + "name": { + "minLength": 1, + "type": "string" + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "attributesInCreateTodoItemRequest": { + "allOf": [ + { + "$ref": "#/components/schemas/attributesInCreateRequest" + }, + { + "required": [ + "description", + "priority" + ], + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "priority": { + "allOf": [ + { + "$ref": "#/components/schemas/todoItemPriority" + } + ] + }, + "durationInHours": { + "type": "integer", + "format": "int64", + "nullable": true + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "attributesInPersonResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/attributesInResponse" + }, + { + "type": "object", + "properties": { + "firstName": { + "type": "string", + "nullable": true + }, + "lastName": { + "type": "string" + }, + "displayName": { + "type": "string", + "readOnly": true + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "attributesInResponse": { + "required": [ + "openapi:discriminator" + ], + "type": "object", + "properties": { + "openapi:discriminator": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceType" + } + ] + } + }, + "additionalProperties": false, + "discriminator": { + "propertyName": "openapi:discriminator", + "mapping": { + "people": "#/components/schemas/attributesInPersonResponse", + "tags": "#/components/schemas/attributesInTagResponse", + "todoItems": "#/components/schemas/attributesInTodoItemResponse" + } + }, + "x-abstract": true + }, + "attributesInTagResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/attributesInResponse" + }, + { + "type": "object", + "properties": { + "name": { + "minLength": 1, + "type": "string" + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "attributesInTodoItemResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/attributesInResponse" + }, + { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "priority": { + "allOf": [ + { + "$ref": "#/components/schemas/todoItemPriority" + } + ] + }, + "durationInHours": { + "type": "integer", + "format": "int64", + "nullable": true + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "modifiedAt": { + "type": "string", + "format": "date-time", + "nullable": true + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "attributesInUpdatePersonRequest": { + "allOf": [ + { + "$ref": "#/components/schemas/attributesInUpdateRequest" + }, + { + "type": "object", + "properties": { + "firstName": { + "type": "string", + "nullable": true + }, + "lastName": { + "type": "string" + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "attributesInUpdateRequest": { + "required": [ + "openapi:discriminator" + ], + "type": "object", + "properties": { + "openapi:discriminator": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceType" + } + ] + } + }, + "additionalProperties": false, + "discriminator": { + "propertyName": "openapi:discriminator", + "mapping": { + "people": "#/components/schemas/attributesInUpdatePersonRequest", + "tags": "#/components/schemas/attributesInUpdateTagRequest", + "todoItems": "#/components/schemas/attributesInUpdateTodoItemRequest" + } + }, + "x-abstract": true + }, + "attributesInUpdateTagRequest": { + "allOf": [ + { + "$ref": "#/components/schemas/attributesInUpdateRequest" + }, + { + "type": "object", + "properties": { + "name": { + "minLength": 1, + "type": "string" + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "attributesInUpdateTodoItemRequest": { + "allOf": [ + { + "$ref": "#/components/schemas/attributesInUpdateRequest" + }, + { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "priority": { + "allOf": [ + { + "$ref": "#/components/schemas/todoItemPriority" + } + ] + }, + "durationInHours": { + "type": "integer", + "format": "int64", + "nullable": true + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "createPersonOperation": { + "allOf": [ + { + "$ref": "#/components/schemas/atomicOperation" + }, + { + "required": [ + "data", + "op" + ], + "type": "object", + "properties": { + "op": { + "allOf": [ + { + "$ref": "#/components/schemas/addOperationCode" + } + ] + }, + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/dataInCreatePersonRequest" + } + ] + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "createPersonRequestDocument": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/dataInCreatePersonRequest" + } + ] + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "createTagOperation": { + "allOf": [ + { + "$ref": "#/components/schemas/atomicOperation" + }, + { + "required": [ + "data", + "op" + ], + "type": "object", + "properties": { + "op": { + "allOf": [ + { + "$ref": "#/components/schemas/addOperationCode" + } + ] + }, + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/dataInCreateTagRequest" + } + ] + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "createTagRequestDocument": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/dataInCreateTagRequest" + } + ] + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "createTodoItemOperation": { + "allOf": [ + { + "$ref": "#/components/schemas/atomicOperation" + }, + { + "required": [ + "data", + "op" + ], + "type": "object", + "properties": { + "op": { + "allOf": [ + { + "$ref": "#/components/schemas/addOperationCode" + } + ] + }, + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/dataInCreateTodoItemRequest" + } + ] + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "createTodoItemRequestDocument": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/dataInCreateTodoItemRequest" + } + ] + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "dataInCreatePersonRequest": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/resourceInCreateRequest" + }, + { + "type": "object", + "properties": { + "lid": { + "minLength": 1, + "type": "string" + }, + "attributes": { + "allOf": [ + { + "$ref": "#/components/schemas/attributesInCreatePersonRequest" + } + ] + }, + "relationships": { + "allOf": [ + { + "$ref": "#/components/schemas/relationshipsInCreatePersonRequest" + } + ] + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "dataInCreateTagRequest": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/resourceInCreateRequest" + }, + { + "type": "object", + "properties": { + "lid": { + "minLength": 1, + "type": "string" + }, + "attributes": { + "allOf": [ + { + "$ref": "#/components/schemas/attributesInCreateTagRequest" + } + ] + }, + "relationships": { + "allOf": [ + { + "$ref": "#/components/schemas/relationshipsInCreateTagRequest" + } + ] + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "dataInCreateTodoItemRequest": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/resourceInCreateRequest" + }, + { + "type": "object", + "properties": { + "lid": { + "minLength": 1, + "type": "string" + }, + "attributes": { + "allOf": [ + { + "$ref": "#/components/schemas/attributesInCreateTodoItemRequest" + } + ] + }, + "relationships": { + "allOf": [ + { + "$ref": "#/components/schemas/relationshipsInCreateTodoItemRequest" + } + ] + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "dataInPersonResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceInResponse" + }, + { + "required": [ + "id" + ], + "type": "object", + "properties": { + "id": { + "minLength": 1, + "type": "string", + "format": "int64" + }, + "attributes": { + "allOf": [ + { + "$ref": "#/components/schemas/attributesInPersonResponse" + } + ] + }, + "relationships": { + "allOf": [ + { + "$ref": "#/components/schemas/relationshipsInPersonResponse" + } + ] + }, + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceLinks" + } + ] + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "dataInTagResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceInResponse" + }, + { + "required": [ + "id" + ], + "type": "object", + "properties": { + "id": { + "minLength": 1, + "type": "string", + "format": "int64" + }, + "attributes": { + "allOf": [ + { + "$ref": "#/components/schemas/attributesInTagResponse" + } + ] + }, + "relationships": { + "allOf": [ + { + "$ref": "#/components/schemas/relationshipsInTagResponse" + } + ] + }, + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceLinks" + } + ] + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "dataInTodoItemResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceInResponse" + }, + { + "required": [ + "id" + ], + "type": "object", + "properties": { + "id": { + "minLength": 1, + "type": "string", + "format": "int64" + }, + "attributes": { + "allOf": [ + { + "$ref": "#/components/schemas/attributesInTodoItemResponse" + } + ] + }, + "relationships": { + "allOf": [ + { + "$ref": "#/components/schemas/relationshipsInTodoItemResponse" + } + ] + }, + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceLinks" + } + ] + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "dataInUpdatePersonRequest": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/resourceInUpdateRequest" + }, + { + "type": "object", + "properties": { + "id": { + "minLength": 1, + "type": "string", + "format": "int64" + }, + "lid": { + "minLength": 1, + "type": "string" + }, + "attributes": { + "allOf": [ + { + "$ref": "#/components/schemas/attributesInUpdatePersonRequest" + } + ] + }, + "relationships": { + "allOf": [ + { + "$ref": "#/components/schemas/relationshipsInUpdatePersonRequest" + } + ] + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "dataInUpdateTagRequest": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/resourceInUpdateRequest" + }, + { + "type": "object", + "properties": { + "id": { + "minLength": 1, + "type": "string", + "format": "int64" + }, + "lid": { + "minLength": 1, + "type": "string" + }, + "attributes": { + "allOf": [ + { + "$ref": "#/components/schemas/attributesInUpdateTagRequest" + } + ] + }, + "relationships": { + "allOf": [ + { + "$ref": "#/components/schemas/relationshipsInUpdateTagRequest" + } + ] + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "dataInUpdateTodoItemRequest": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/resourceInUpdateRequest" + }, + { + "type": "object", + "properties": { + "id": { + "minLength": 1, + "type": "string", + "format": "int64" + }, + "lid": { + "minLength": 1, + "type": "string" + }, + "attributes": { + "allOf": [ + { + "$ref": "#/components/schemas/attributesInUpdateTodoItemRequest" + } + ] + }, + "relationships": { + "allOf": [ + { + "$ref": "#/components/schemas/relationshipsInUpdateTodoItemRequest" + } + ] + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "deletePersonOperation": { + "allOf": [ + { + "$ref": "#/components/schemas/atomicOperation" + }, + { + "required": [ + "op", + "ref" + ], + "type": "object", + "properties": { + "op": { + "allOf": [ + { + "$ref": "#/components/schemas/removeOperationCode" + } + ] + }, + "ref": { + "allOf": [ + { + "$ref": "#/components/schemas/personIdentifierInRequest" + } + ] + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "deleteTagOperation": { + "allOf": [ + { + "$ref": "#/components/schemas/atomicOperation" + }, + { + "required": [ + "op", + "ref" + ], + "type": "object", + "properties": { + "op": { + "allOf": [ + { + "$ref": "#/components/schemas/removeOperationCode" + } + ] + }, + "ref": { + "allOf": [ + { + "$ref": "#/components/schemas/tagIdentifierInRequest" + } + ] + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "deleteTodoItemOperation": { + "allOf": [ + { + "$ref": "#/components/schemas/atomicOperation" + }, + { + "required": [ + "op", + "ref" + ], + "type": "object", + "properties": { + "op": { + "allOf": [ + { + "$ref": "#/components/schemas/removeOperationCode" + } + ] + }, + "ref": { + "allOf": [ + { + "$ref": "#/components/schemas/todoItemIdentifierInRequest" + } + ] + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "errorLinks": { + "type": "object", + "properties": { + "about": { + "type": "string", + "nullable": true + }, + "type": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "errorObject": { + "type": "object", + "properties": { + "id": { + "type": "string", + "nullable": true + }, + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/errorLinks" + } + ], + "nullable": true + }, + "status": { + "type": "string" + }, + "code": { + "type": "string", + "nullable": true + }, + "title": { + "type": "string", + "nullable": true + }, + "detail": { + "type": "string", + "nullable": true + }, + "source": { + "allOf": [ + { + "$ref": "#/components/schemas/errorSource" + } + ], + "nullable": true + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "errorResponseDocument": { + "required": [ + "errors", + "links" + ], + "type": "object", + "properties": { + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/errorTopLevelLinks" + } + ] + }, + "errors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/errorObject" + } + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "errorSource": { + "type": "object", + "properties": { + "pointer": { + "type": "string", + "nullable": true + }, + "parameter": { + "type": "string", + "nullable": true + }, + "header": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "errorTopLevelLinks": { + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "describedby": { + "type": "string" + } + }, + "additionalProperties": false + }, + "identifierInRequest": { + "required": [ + "type" + ], + "type": "object", + "properties": { + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceType" + } + ] + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false, + "discriminator": { + "propertyName": "type", + "mapping": { + "people": "#/components/schemas/personIdentifierInRequest", + "tags": "#/components/schemas/tagIdentifierInRequest", + "todoItems": "#/components/schemas/todoItemIdentifierInRequest" + } + }, + "x-abstract": true + }, + "meta": { + "type": "object", + "additionalProperties": { + "nullable": true + } + }, + "nullablePersonIdentifierResponseDocument": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceIdentifierTopLevelLinks" + } + ] + }, + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/personIdentifierInResponse" + } + ], + "nullable": true + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "nullableSecondaryPersonResponseDocument": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceTopLevelLinks" + } + ] + }, + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/dataInPersonResponse" + } + ], + "nullable": true + }, + "included": { + "type": "array", + "items": { + "$ref": "#/components/schemas/resourceInResponse" + } + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "nullableToOnePersonInRequest": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/personIdentifierInRequest" + } + ], + "nullable": true + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "nullableToOnePersonInResponse": { + "type": "object", + "properties": { + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/relationshipLinks" + } + ] + }, + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/personIdentifierInResponse" + } + ], + "nullable": true + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "operationsRequestDocument": { + "required": [ + "atomic:operations" + ], + "type": "object", + "properties": { + "atomic:operations": { + "minItems": 1, + "type": "array", + "items": { + "$ref": "#/components/schemas/atomicOperation" + } + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "operationsResponseDocument": { + "required": [ + "atomic:results", + "links" + ], + "type": "object", + "properties": { + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceTopLevelLinks" + } + ] + }, + "atomic:results": { + "minItems": 1, + "type": "array", + "items": { + "$ref": "#/components/schemas/atomicResult" + } + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "personAssignedTodoItemsRelationshipIdentifier": { + "required": [ + "relationship", + "type" + ], + "type": "object", + "properties": { + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/personResourceType" + } + ] + }, + "id": { + "minLength": 1, + "type": "string", + "format": "int64" + }, + "lid": { + "minLength": 1, + "type": "string" + }, + "relationship": { + "allOf": [ + { + "$ref": "#/components/schemas/personAssignedTodoItemsRelationshipName" + } + ] + } + }, + "additionalProperties": false + }, + "personAssignedTodoItemsRelationshipName": { + "enum": [ + "assignedTodoItems" + ], + "type": "string" + }, + "personCollectionResponseDocument": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceCollectionTopLevelLinks" + } + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/dataInPersonResponse" + } + }, + "included": { + "type": "array", + "items": { + "$ref": "#/components/schemas/resourceInResponse" + } + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "personIdentifierInRequest": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/identifierInRequest" + }, + { + "type": "object", + "properties": { + "id": { + "minLength": 1, + "type": "string", + "format": "int64" + }, + "lid": { + "minLength": 1, + "type": "string" + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "personIdentifierInResponse": { + "required": [ + "id", + "type" + ], + "type": "object", + "properties": { + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/personResourceType" + } + ] + }, + "id": { + "minLength": 1, + "type": "string", + "format": "int64" + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "personIdentifierResponseDocument": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceIdentifierTopLevelLinks" + } + ] + }, + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/personIdentifierInResponse" + } + ] + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "personOwnedTodoItemsRelationshipIdentifier": { + "required": [ + "relationship", + "type" + ], + "type": "object", + "properties": { + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/personResourceType" + } + ] + }, + "id": { + "minLength": 1, + "type": "string", + "format": "int64" + }, + "lid": { + "minLength": 1, + "type": "string" + }, + "relationship": { + "allOf": [ + { + "$ref": "#/components/schemas/personOwnedTodoItemsRelationshipName" + } + ] + } + }, + "additionalProperties": false + }, + "personOwnedTodoItemsRelationshipName": { + "enum": [ + "ownedTodoItems" + ], + "type": "string" + }, + "personResourceType": { + "enum": [ + "people" + ], + "type": "string" + }, + "primaryPersonResponseDocument": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceTopLevelLinks" + } + ] + }, + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/dataInPersonResponse" + } + ] + }, + "included": { + "type": "array", + "items": { + "$ref": "#/components/schemas/resourceInResponse" + } + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "primaryTagResponseDocument": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceTopLevelLinks" + } + ] + }, + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/dataInTagResponse" + } + ] + }, + "included": { + "type": "array", + "items": { + "$ref": "#/components/schemas/resourceInResponse" + } + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "primaryTodoItemResponseDocument": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceTopLevelLinks" + } + ] + }, + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/dataInTodoItemResponse" + } + ] + }, + "included": { + "type": "array", + "items": { + "$ref": "#/components/schemas/resourceInResponse" + } + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "relationshipLinks": { + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "related": { + "type": "string" + } + }, + "additionalProperties": false + }, + "relationshipsInCreatePersonRequest": { + "allOf": [ + { + "$ref": "#/components/schemas/relationshipsInCreateRequest" + }, + { + "type": "object", + "properties": { + "ownedTodoItems": { + "allOf": [ + { + "$ref": "#/components/schemas/toManyTodoItemInRequest" + } + ] + }, + "assignedTodoItems": { + "allOf": [ + { + "$ref": "#/components/schemas/toManyTodoItemInRequest" + } + ] + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "relationshipsInCreateRequest": { + "required": [ + "openapi:discriminator" + ], + "type": "object", + "properties": { + "openapi:discriminator": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceType" + } + ] + } + }, + "additionalProperties": false, + "discriminator": { + "propertyName": "openapi:discriminator", + "mapping": { + "people": "#/components/schemas/relationshipsInCreatePersonRequest", + "tags": "#/components/schemas/relationshipsInCreateTagRequest", + "todoItems": "#/components/schemas/relationshipsInCreateTodoItemRequest" + } + }, + "x-abstract": true + }, + "relationshipsInCreateTagRequest": { + "allOf": [ + { + "$ref": "#/components/schemas/relationshipsInCreateRequest" + }, + { + "type": "object", + "properties": { + "todoItems": { + "allOf": [ + { + "$ref": "#/components/schemas/toManyTodoItemInRequest" + } + ] + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "relationshipsInCreateTodoItemRequest": { + "allOf": [ + { + "$ref": "#/components/schemas/relationshipsInCreateRequest" + }, + { + "required": [ + "owner" + ], + "type": "object", + "properties": { + "owner": { + "allOf": [ + { + "$ref": "#/components/schemas/toOnePersonInRequest" + } + ] + }, + "assignee": { + "allOf": [ + { + "$ref": "#/components/schemas/nullableToOnePersonInRequest" + } + ] + }, + "tags": { + "allOf": [ + { + "$ref": "#/components/schemas/toManyTagInRequest" + } + ] + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "relationshipsInPersonResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/relationshipsInResponse" + }, + { + "type": "object", + "properties": { + "ownedTodoItems": { + "allOf": [ + { + "$ref": "#/components/schemas/toManyTodoItemInResponse" + } + ] + }, + "assignedTodoItems": { + "allOf": [ + { + "$ref": "#/components/schemas/toManyTodoItemInResponse" + } + ] + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "relationshipsInResponse": { + "required": [ + "openapi:discriminator" + ], + "type": "object", + "properties": { + "openapi:discriminator": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceType" + } + ] + } + }, + "additionalProperties": false, + "discriminator": { + "propertyName": "openapi:discriminator", + "mapping": { + "people": "#/components/schemas/relationshipsInPersonResponse", + "tags": "#/components/schemas/relationshipsInTagResponse", + "todoItems": "#/components/schemas/relationshipsInTodoItemResponse" + } + }, + "x-abstract": true + }, + "relationshipsInTagResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/relationshipsInResponse" + }, + { + "type": "object", + "properties": { + "todoItems": { + "allOf": [ + { + "$ref": "#/components/schemas/toManyTodoItemInResponse" + } + ] + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "relationshipsInTodoItemResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/relationshipsInResponse" + }, + { + "type": "object", + "properties": { + "owner": { + "allOf": [ + { + "$ref": "#/components/schemas/toOnePersonInResponse" + } + ] + }, + "assignee": { + "allOf": [ + { + "$ref": "#/components/schemas/nullableToOnePersonInResponse" + } + ] + }, + "tags": { + "allOf": [ + { + "$ref": "#/components/schemas/toManyTagInResponse" + } + ] + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "relationshipsInUpdatePersonRequest": { + "allOf": [ + { + "$ref": "#/components/schemas/relationshipsInUpdateRequest" + }, + { + "type": "object", + "properties": { + "ownedTodoItems": { + "allOf": [ + { + "$ref": "#/components/schemas/toManyTodoItemInRequest" + } + ] + }, + "assignedTodoItems": { + "allOf": [ + { + "$ref": "#/components/schemas/toManyTodoItemInRequest" + } + ] + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "relationshipsInUpdateRequest": { + "required": [ + "openapi:discriminator" + ], + "type": "object", + "properties": { + "openapi:discriminator": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceType" + } + ] + } + }, + "additionalProperties": false, + "discriminator": { + "propertyName": "openapi:discriminator", + "mapping": { + "people": "#/components/schemas/relationshipsInUpdatePersonRequest", + "tags": "#/components/schemas/relationshipsInUpdateTagRequest", + "todoItems": "#/components/schemas/relationshipsInUpdateTodoItemRequest" + } + }, + "x-abstract": true + }, + "relationshipsInUpdateTagRequest": { + "allOf": [ + { + "$ref": "#/components/schemas/relationshipsInUpdateRequest" + }, + { + "type": "object", + "properties": { + "todoItems": { + "allOf": [ + { + "$ref": "#/components/schemas/toManyTodoItemInRequest" + } + ] + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "relationshipsInUpdateTodoItemRequest": { + "allOf": [ + { + "$ref": "#/components/schemas/relationshipsInUpdateRequest" + }, + { + "type": "object", + "properties": { + "owner": { + "allOf": [ + { + "$ref": "#/components/schemas/toOnePersonInRequest" + } + ] + }, + "assignee": { + "allOf": [ + { + "$ref": "#/components/schemas/nullableToOnePersonInRequest" + } + ] + }, + "tags": { + "allOf": [ + { + "$ref": "#/components/schemas/toManyTagInRequest" + } + ] + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "removeFromPersonAssignedTodoItemsRelationshipOperation": { + "allOf": [ + { + "$ref": "#/components/schemas/atomicOperation" + }, + { + "required": [ + "data", + "op", + "ref" + ], + "type": "object", + "properties": { + "op": { + "allOf": [ + { + "$ref": "#/components/schemas/removeOperationCode" + } + ] + }, + "ref": { + "allOf": [ + { + "$ref": "#/components/schemas/personAssignedTodoItemsRelationshipIdentifier" + } + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/todoItemIdentifierInRequest" + } + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "removeFromPersonOwnedTodoItemsRelationshipOperation": { + "allOf": [ + { + "$ref": "#/components/schemas/atomicOperation" + }, + { + "required": [ + "data", + "op", + "ref" + ], + "type": "object", + "properties": { + "op": { + "allOf": [ + { + "$ref": "#/components/schemas/removeOperationCode" + } + ] + }, + "ref": { + "allOf": [ + { + "$ref": "#/components/schemas/personOwnedTodoItemsRelationshipIdentifier" + } + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/todoItemIdentifierInRequest" + } + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "removeFromTagTodoItemsRelationshipOperation": { + "allOf": [ + { + "$ref": "#/components/schemas/atomicOperation" + }, + { + "required": [ + "data", + "op", + "ref" + ], + "type": "object", + "properties": { + "op": { + "allOf": [ + { + "$ref": "#/components/schemas/removeOperationCode" + } + ] + }, + "ref": { + "allOf": [ + { + "$ref": "#/components/schemas/tagTodoItemsRelationshipIdentifier" + } + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/todoItemIdentifierInRequest" + } + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "removeFromTodoItemTagsRelationshipOperation": { + "allOf": [ + { + "$ref": "#/components/schemas/atomicOperation" + }, + { + "required": [ + "data", + "op", + "ref" + ], + "type": "object", + "properties": { + "op": { + "allOf": [ + { + "$ref": "#/components/schemas/removeOperationCode" + } + ] + }, + "ref": { + "allOf": [ + { + "$ref": "#/components/schemas/todoItemTagsRelationshipIdentifier" + } + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/tagIdentifierInRequest" + } + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "removeOperationCode": { + "enum": [ + "remove" + ], + "type": "string" + }, + "resourceCollectionTopLevelLinks": { + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "first": { + "type": "string" + }, + "last": { + "type": "string" + }, + "prev": { + "type": "string" + }, + "next": { + "type": "string" + } + }, + "additionalProperties": false + }, + "resourceIdentifierCollectionTopLevelLinks": { + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "related": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "first": { + "type": "string" + }, + "last": { + "type": "string" + }, + "prev": { + "type": "string" + }, + "next": { + "type": "string" + } + }, + "additionalProperties": false + }, + "resourceIdentifierTopLevelLinks": { + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "related": { + "type": "string" + }, + "describedby": { + "type": "string" + } + }, + "additionalProperties": false + }, + "resourceInCreateRequest": { + "required": [ + "type" + ], + "type": "object", + "properties": { + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceType" + } + ] + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false, + "discriminator": { + "propertyName": "type", + "mapping": { + "people": "#/components/schemas/dataInCreatePersonRequest", + "tags": "#/components/schemas/dataInCreateTagRequest", + "todoItems": "#/components/schemas/dataInCreateTodoItemRequest" + } + }, + "x-abstract": true + }, + "resourceInResponse": { + "required": [ + "type" + ], + "type": "object", + "properties": { + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceType" + } + ] + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false, + "discriminator": { + "propertyName": "type", + "mapping": { + "people": "#/components/schemas/dataInPersonResponse", + "tags": "#/components/schemas/dataInTagResponse", + "todoItems": "#/components/schemas/dataInTodoItemResponse" + } + }, + "x-abstract": true + }, + "resourceInUpdateRequest": { + "required": [ + "type" + ], + "type": "object", + "properties": { + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceType" + } + ] + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false, + "discriminator": { + "propertyName": "type", + "mapping": { + "people": "#/components/schemas/dataInUpdatePersonRequest", + "tags": "#/components/schemas/dataInUpdateTagRequest", + "todoItems": "#/components/schemas/dataInUpdateTodoItemRequest" + } + }, + "x-abstract": true + }, + "resourceLinks": { + "type": "object", + "properties": { + "self": { + "type": "string" + } + }, + "additionalProperties": false + }, + "resourceTopLevelLinks": { + "type": "object", + "properties": { + "self": { + "type": "string" + }, + "describedby": { + "type": "string" + } + }, + "additionalProperties": false + }, + "resourceType": { + "enum": [ + "people", + "tags", + "todoItems" + ], + "type": "string" + }, + "secondaryPersonResponseDocument": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceTopLevelLinks" + } + ] + }, + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/dataInPersonResponse" + } + ] + }, + "included": { + "type": "array", + "items": { + "$ref": "#/components/schemas/resourceInResponse" + } + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "tagCollectionResponseDocument": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceCollectionTopLevelLinks" + } + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/dataInTagResponse" + } + }, + "included": { + "type": "array", + "items": { + "$ref": "#/components/schemas/resourceInResponse" + } + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "tagIdentifierCollectionResponseDocument": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceIdentifierCollectionTopLevelLinks" + } + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/tagIdentifierInResponse" + } + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "tagIdentifierInRequest": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/identifierInRequest" + }, + { + "type": "object", + "properties": { + "id": { + "minLength": 1, + "type": "string", + "format": "int64" + }, + "lid": { + "minLength": 1, + "type": "string" + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "tagIdentifierInResponse": { + "required": [ + "id", + "type" + ], + "type": "object", + "properties": { + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/tagResourceType" + } + ] + }, + "id": { + "minLength": 1, + "type": "string", + "format": "int64" + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "tagResourceType": { + "enum": [ + "tags" + ], + "type": "string" + }, + "tagTodoItemsRelationshipIdentifier": { + "required": [ + "relationship", + "type" + ], + "type": "object", + "properties": { + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/tagResourceType" + } + ] + }, + "id": { + "minLength": 1, + "type": "string", + "format": "int64" + }, + "lid": { + "minLength": 1, + "type": "string" + }, + "relationship": { + "allOf": [ + { + "$ref": "#/components/schemas/tagTodoItemsRelationshipName" + } + ] + } + }, + "additionalProperties": false + }, + "tagTodoItemsRelationshipName": { + "enum": [ + "todoItems" + ], + "type": "string" + }, + "toManyTagInRequest": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/tagIdentifierInRequest" + } + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "toManyTagInResponse": { + "type": "object", + "properties": { + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/relationshipLinks" + } + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/tagIdentifierInResponse" + } + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "toManyTodoItemInRequest": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/todoItemIdentifierInRequest" + } + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "toManyTodoItemInResponse": { + "type": "object", + "properties": { + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/relationshipLinks" + } + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/todoItemIdentifierInResponse" + } + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "toOnePersonInRequest": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/personIdentifierInRequest" + } + ] + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "toOnePersonInResponse": { + "type": "object", + "properties": { + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/relationshipLinks" + } + ] + }, + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/personIdentifierInResponse" + } + ] + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "todoItemAssigneeRelationshipIdentifier": { + "required": [ + "relationship", + "type" + ], + "type": "object", + "properties": { + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/todoItemResourceType" + } + ] + }, + "id": { + "minLength": 1, + "type": "string", + "format": "int64" + }, + "lid": { + "minLength": 1, + "type": "string" + }, + "relationship": { + "allOf": [ + { + "$ref": "#/components/schemas/todoItemAssigneeRelationshipName" + } + ] + } + }, + "additionalProperties": false + }, + "todoItemAssigneeRelationshipName": { + "enum": [ + "assignee" + ], + "type": "string" + }, + "todoItemCollectionResponseDocument": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceCollectionTopLevelLinks" + } + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/dataInTodoItemResponse" + } + }, + "included": { + "type": "array", + "items": { + "$ref": "#/components/schemas/resourceInResponse" + } + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "todoItemIdentifierCollectionResponseDocument": { + "required": [ + "data", + "links" + ], + "type": "object", + "properties": { + "links": { + "allOf": [ + { + "$ref": "#/components/schemas/resourceIdentifierCollectionTopLevelLinks" + } + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/todoItemIdentifierInResponse" + } + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "todoItemIdentifierInRequest": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/identifierInRequest" + }, + { + "type": "object", + "properties": { + "id": { + "minLength": 1, + "type": "string", + "format": "int64" + }, + "lid": { + "minLength": 1, + "type": "string" + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "todoItemIdentifierInResponse": { + "required": [ + "id", + "type" + ], + "type": "object", + "properties": { + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/todoItemResourceType" + } + ] + }, + "id": { + "minLength": 1, + "type": "string", + "format": "int64" + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "todoItemOwnerRelationshipIdentifier": { + "required": [ + "relationship", + "type" + ], + "type": "object", + "properties": { + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/todoItemResourceType" + } + ] + }, + "id": { + "minLength": 1, + "type": "string", + "format": "int64" + }, + "lid": { + "minLength": 1, + "type": "string" + }, + "relationship": { + "allOf": [ + { + "$ref": "#/components/schemas/todoItemOwnerRelationshipName" + } + ] + } + }, + "additionalProperties": false + }, + "todoItemOwnerRelationshipName": { + "enum": [ + "owner" + ], + "type": "string" + }, + "todoItemPriority": { + "enum": [ + "High", + "Medium", + "Low" + ], + "type": "string" + }, + "todoItemResourceType": { + "enum": [ + "todoItems" + ], + "type": "string" + }, + "todoItemTagsRelationshipIdentifier": { + "required": [ + "relationship", + "type" + ], + "type": "object", + "properties": { + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/todoItemResourceType" + } + ] + }, + "id": { + "minLength": 1, + "type": "string", + "format": "int64" + }, + "lid": { + "minLength": 1, + "type": "string" + }, + "relationship": { + "allOf": [ + { + "$ref": "#/components/schemas/todoItemTagsRelationshipName" + } + ] + } + }, + "additionalProperties": false + }, + "todoItemTagsRelationshipName": { + "enum": [ + "tags" + ], + "type": "string" + }, + "updateOperationCode": { + "enum": [ + "update" + ], + "type": "string" + }, + "updatePersonAssignedTodoItemsRelationshipOperation": { + "allOf": [ + { + "$ref": "#/components/schemas/atomicOperation" + }, + { + "required": [ + "data", + "op", + "ref" + ], + "type": "object", + "properties": { + "op": { + "allOf": [ + { + "$ref": "#/components/schemas/updateOperationCode" + } + ] + }, + "ref": { + "allOf": [ + { + "$ref": "#/components/schemas/personAssignedTodoItemsRelationshipIdentifier" + } + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/todoItemIdentifierInRequest" + } + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "updatePersonOperation": { + "allOf": [ + { + "$ref": "#/components/schemas/atomicOperation" + }, + { + "required": [ + "data", + "op" + ], + "type": "object", + "properties": { + "op": { + "allOf": [ + { + "$ref": "#/components/schemas/updateOperationCode" + } + ] + }, + "ref": { + "allOf": [ + { + "$ref": "#/components/schemas/personIdentifierInRequest" + } + ] + }, + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/dataInUpdatePersonRequest" + } + ] + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "updatePersonOwnedTodoItemsRelationshipOperation": { + "allOf": [ + { + "$ref": "#/components/schemas/atomicOperation" + }, + { + "required": [ + "data", + "op", + "ref" + ], + "type": "object", + "properties": { + "op": { + "allOf": [ + { + "$ref": "#/components/schemas/updateOperationCode" + } + ] + }, + "ref": { + "allOf": [ + { + "$ref": "#/components/schemas/personOwnedTodoItemsRelationshipIdentifier" + } + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/todoItemIdentifierInRequest" + } + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "updatePersonRequestDocument": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/dataInUpdatePersonRequest" + } + ] + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "updateTagOperation": { + "allOf": [ + { + "$ref": "#/components/schemas/atomicOperation" + }, + { + "required": [ + "data", + "op" + ], + "type": "object", + "properties": { + "op": { + "allOf": [ + { + "$ref": "#/components/schemas/updateOperationCode" + } + ] + }, + "ref": { + "allOf": [ + { + "$ref": "#/components/schemas/tagIdentifierInRequest" + } + ] + }, + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/dataInUpdateTagRequest" + } + ] + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "updateTagRequestDocument": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/dataInUpdateTagRequest" + } + ] + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "updateTagTodoItemsRelationshipOperation": { + "allOf": [ + { + "$ref": "#/components/schemas/atomicOperation" + }, + { + "required": [ + "data", + "op", + "ref" + ], + "type": "object", + "properties": { + "op": { + "allOf": [ + { + "$ref": "#/components/schemas/updateOperationCode" + } + ] + }, + "ref": { + "allOf": [ + { + "$ref": "#/components/schemas/tagTodoItemsRelationshipIdentifier" + } + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/todoItemIdentifierInRequest" + } + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "updateTodoItemAssigneeRelationshipOperation": { + "allOf": [ + { + "$ref": "#/components/schemas/atomicOperation" + }, + { + "required": [ + "data", + "op", + "ref" + ], + "type": "object", + "properties": { + "op": { + "allOf": [ + { + "$ref": "#/components/schemas/updateOperationCode" + } + ] + }, + "ref": { + "allOf": [ + { + "$ref": "#/components/schemas/todoItemAssigneeRelationshipIdentifier" + } + ] + }, + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/personIdentifierInRequest" + } + ], + "nullable": true + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "updateTodoItemOperation": { + "allOf": [ + { + "$ref": "#/components/schemas/atomicOperation" + }, + { + "required": [ + "data", + "op" + ], + "type": "object", + "properties": { + "op": { + "allOf": [ + { + "$ref": "#/components/schemas/updateOperationCode" + } + ] + }, + "ref": { + "allOf": [ + { + "$ref": "#/components/schemas/todoItemIdentifierInRequest" + } + ] + }, + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/dataInUpdateTodoItemRequest" + } + ] + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "updateTodoItemOwnerRelationshipOperation": { + "allOf": [ + { + "$ref": "#/components/schemas/atomicOperation" + }, + { + "required": [ + "data", + "op", + "ref" + ], + "type": "object", + "properties": { + "op": { + "allOf": [ + { + "$ref": "#/components/schemas/updateOperationCode" + } + ] + }, + "ref": { + "allOf": [ + { + "$ref": "#/components/schemas/todoItemOwnerRelationshipIdentifier" + } + ] + }, + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/personIdentifierInRequest" + } + ] + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + }, + "updateTodoItemRequestDocument": { + "required": [ + "data" + ], + "type": "object", + "properties": { + "data": { + "allOf": [ + { + "$ref": "#/components/schemas/dataInUpdateTodoItemRequest" + } + ] + }, + "meta": { + "allOf": [ + { + "$ref": "#/components/schemas/meta" + } + ] + } + }, + "additionalProperties": false + }, + "updateTodoItemTagsRelationshipOperation": { + "allOf": [ + { + "$ref": "#/components/schemas/atomicOperation" + }, + { + "required": [ + "data", + "op", + "ref" + ], + "type": "object", + "properties": { + "op": { + "allOf": [ + { + "$ref": "#/components/schemas/updateOperationCode" + } + ] + }, + "ref": { + "allOf": [ + { + "$ref": "#/components/schemas/todoItemTagsRelationshipIdentifier" + } + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/tagIdentifierInRequest" + } + } + }, + "additionalProperties": false + } + ], + "additionalProperties": false + } + } + } +} \ No newline at end of file diff --git a/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj b/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj index b243e99ec2..768a2de827 100644 --- a/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj +++ b/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj @@ -1,16 +1,25 @@ - $(TargetFrameworkName) + net9.0;net8.0 + true + GeneratedSwagger + + + - - + + + + + + diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs index 5415d37bb3..d11fbffff6 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations.Schema; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -6,7 +7,7 @@ namespace JsonApiDotNetCoreExample.Models; [UsedImplicitly(ImplicitUseTargetFlags.Members)] [Resource] -public sealed class Person : Identifiable +public sealed class Person : Identifiable { [Attr] public string? FirstName { get; set; } @@ -14,6 +15,13 @@ public sealed class Person : Identifiable [Attr] public string LastName { get; set; } = null!; + [Attr(Capabilities = AttrCapabilities.AllowView)] + [NotMapped] + public string DisplayName => FirstName != null ? $"{FirstName} {LastName}" : LastName; + + [HasMany] + public ISet OwnedTodoItems { get; set; } = new HashSet(); + [HasMany] public ISet AssignedTodoItems { get; set; } = new HashSet(); } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs index 9095b0af80..8904ec01a3 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCoreExample.Models; [UsedImplicitly(ImplicitUseTargetFlags.Members)] [Resource] -public sealed class Tag : Identifiable +public sealed class Tag : Identifiable { [Attr] [MinLength(1)] diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs index 9be7e6e64e..68df7cef27 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs @@ -7,7 +7,7 @@ namespace JsonApiDotNetCoreExample.Models; [UsedImplicitly(ImplicitUseTargetFlags.Members)] [Resource] -public sealed class TodoItem : Identifiable +public sealed class TodoItem : Identifiable { [Attr] public string Description { get; set; } = null!; @@ -16,6 +16,9 @@ public sealed class TodoItem : Identifiable [Required] public TodoItemPriority? Priority { get; set; } + [Attr] + public long? DurationInHours { get; set; } + [Attr(Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort | AttrCapabilities.AllowView)] public DateTimeOffset CreatedAt { get; set; } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemPriority.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemPriority.cs index 9ef85348f1..84e3567b31 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemPriority.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemPriority.cs @@ -5,7 +5,7 @@ namespace JsonApiDotNetCoreExample.Models; [UsedImplicitly(ImplicitUseTargetFlags.Members)] public enum TodoItemPriority { - Low, - Medium, - High + High = 1, + Medium = 2, + Low = 3 } diff --git a/src/Examples/JsonApiDotNetCoreExample/Program.cs b/src/Examples/JsonApiDotNetCoreExample/Program.cs index bda826d131..56448b271e 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Program.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Program.cs @@ -1,19 +1,26 @@ +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Diagnostics; +using JsonApiDotNetCore.OpenApi.Swashbuckle; +using JsonApiDotNetCoreExample; using JsonApiDotNetCoreExample.Data; -using Microsoft.AspNetCore.Authentication; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.DependencyInjection.Extensions; +using Scalar.AspNetCore; [assembly: ExcludeFromCodeCoverage] WebApplication app = CreateWebApplication(args); -await CreateDatabaseAsync(app.Services); +if (!IsGeneratingOpenApiDocumentAtBuildTime()) +{ + await CreateDatabaseAsync(app.Services); +} -app.Run(); +await app.RunAsync(); static WebApplication CreateWebApplication(string[] args) { @@ -25,72 +32,87 @@ static WebApplication CreateWebApplication(string[] args) // Add services to the container. ConfigureServices(builder); - WebApplication webApplication = builder.Build(); + WebApplication app = builder.Build(); // Configure the HTTP request pipeline. - ConfigurePipeline(webApplication); + ConfigurePipeline(app); - if (CodeTimingSessionManager.IsEnabled) + if (CodeTimingSessionManager.IsEnabled && app.Logger.IsEnabled(LogLevel.Information)) { string timingResults = CodeTimingSessionManager.Current.GetResults(); - webApplication.Logger.LogInformation($"Measurement results for application startup:{Environment.NewLine}{timingResults}"); + AppLog.LogStartupTimings(app.Logger, Environment.NewLine, timingResults); } - return webApplication; + return app; } static void ConfigureServices(WebApplicationBuilder builder) { using IDisposable _ = CodeTimingSessionManager.Current.Measure("Configure services"); - builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(TimeProvider.System); builder.Services.AddDbContext(options => { - string connectionString = GetConnectionString(builder.Configuration); - + string? connectionString = builder.Configuration.GetConnectionString("Default"); options.UseNpgsql(connectionString); -#if DEBUG - options.EnableSensitiveDataLogging(); - options.EnableDetailedErrors(); -#endif + + SetDbContextDebugOptions(options); }); using (CodeTimingSessionManager.Current.Measure("AddJsonApi()")) { builder.Services.AddJsonApi(options => { - options.Namespace = "api/v1"; + options.Namespace = "api"; options.UseRelativeLinks = true; options.IncludeTotalResourceCount = true; - options.SerializerOptions.WriteIndented = true; options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); + #if DEBUG options.IncludeExceptionStackTraceInErrors = true; options.IncludeRequestBodyInErrors = true; + options.SerializerOptions.WriteIndented = true; #endif }, discovery => discovery.AddCurrentAssembly()); } + + using (CodeTimingSessionManager.Current.Measure("AddOpenApiForJsonApi()")) + { + builder.Services.AddOpenApiForJsonApi(options => options.DocumentFilter()); + } } -static string GetConnectionString(IConfiguration configuration) +[Conditional("DEBUG")] +static void SetDbContextDebugOptions(DbContextOptionsBuilder options) { - string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - return configuration["Data:DefaultConnection"].Replace("###", postgresPassword); + options.EnableDetailedErrors(); + options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(builder => builder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); } -static void ConfigurePipeline(WebApplication webApplication) +static void ConfigurePipeline(WebApplication app) { using IDisposable _ = CodeTimingSessionManager.Current.Measure("Configure pipeline"); - webApplication.UseRouting(); + app.UseRouting(); using (CodeTimingSessionManager.Current.Measure("UseJsonApi()")) { - webApplication.UseJsonApi(); + app.UseJsonApi(); } - webApplication.MapControllers(); + app.UseSwagger(); + app.UseSwaggerUI(); + app.UseReDoc(); + app.MapScalarApiReference(options => options.OpenApiRoutePattern = "/swagger/{documentName}/swagger.json"); + + app.MapControllers(); +} + +static bool IsGeneratingOpenApiDocumentAtBuildTime() +{ + return Environment.GetCommandLineArgs().Any(argument => argument.Contains("GetDocument.Insider")); } static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) @@ -98,5 +120,9 @@ static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); var dbContext = scope.ServiceProvider.GetRequiredService(); - await dbContext.Database.EnsureCreatedAsync(); + + if (await dbContext.Database.EnsureCreatedAsync()) + { + await Seeder.CreateSampleDataAsync(dbContext); + } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json b/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json index 6a5108a8ad..7c4189f272 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json +++ b/src/Examples/JsonApiDotNetCoreExample/Properties/launchSettings.json @@ -1,5 +1,5 @@ { - "$schema": "http://json.schemastore.org/launchsettings.json", + "$schema": "https://json.schemastore.org/launchsettings.json", "iisSettings": { "windowsAuthentication": false, "anonymousAuthentication": true, @@ -11,16 +11,16 @@ "profiles": { "IIS Express": { "commandName": "IISExpress", - "launchBrowser": false, - "launchUrl": "api/v1/todoItems", + "launchBrowser": true, + "launchUrl": "swagger", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } }, "Kestrel": { "commandName": "Project", - "launchBrowser": false, - "launchUrl": "api/v1/todoItems", + "launchBrowser": true, + "launchUrl": "swagger", "applicationUrl": "https://localhost:44340;http://localhost:14140", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/src/Examples/JsonApiDotNetCoreExample/SetOpenApiServerAtBuildTimeFilter.cs b/src/Examples/JsonApiDotNetCoreExample/SetOpenApiServerAtBuildTimeFilter.cs new file mode 100644 index 0000000000..894c0d0966 --- /dev/null +++ b/src/Examples/JsonApiDotNetCoreExample/SetOpenApiServerAtBuildTimeFilter.cs @@ -0,0 +1,25 @@ +using JetBrains.Annotations; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCoreExample; + +/// +/// This is normally not needed. It ensures the server URL is added to the OpenAPI file during build. +/// +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +internal sealed class SetOpenApiServerAtBuildTimeFilter(IHttpContextAccessor httpContextAccessor) : IDocumentFilter +{ + private readonly IHttpContextAccessor _httpContextAccessor = httpContextAccessor; + + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) + { + if (_httpContextAccessor.HttpContext == null) + { + swaggerDoc.Servers.Add(new OpenApiServer + { + Url = "https://localhost:44340" + }); + } + } +} diff --git a/src/Examples/JsonApiDotNetCoreExample/appsettings.json b/src/Examples/JsonApiDotNetCoreExample/appsettings.json index 0e63c6a380..418fcb7812 100644 --- a/src/Examples/JsonApiDotNetCoreExample/appsettings.json +++ b/src/Examples/JsonApiDotNetCoreExample/appsettings.json @@ -1,15 +1,16 @@ { - "Data": { - "DefaultConnection": "Host=localhost;Port=5432;Database=JsonApiDotNetCoreExample;User ID=postgres;Password=###" + "ConnectionStrings": { + "Default": "Host=localhost;Database=JsonApiDotNetCoreExample;User ID=postgres;Password=postgres;Include Error Detail=true" }, "Logging": { "LogLevel": { "Default": "Warning", - "Microsoft.Hosting.Lifetime": "Warning", - "Microsoft.EntityFrameworkCore.Update": "Critical", - "Microsoft.EntityFrameworkCore.Database.Command": "Critical", - "JsonApiDotNetCore.Middleware.JsonApiMiddleware": "Information", - "Program": "Information" + // Include server startup, JsonApiDotNetCore measurements, incoming requests and SQL commands. + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore.Hosting.Diagnostics": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information", + "JsonApiDotNetCore": "Information", + "JsonApiDotNetCoreExample": "Information" } }, "AllowedHosts": "*" diff --git a/src/Examples/MultiDbContextExample/Data/DbContextA.cs b/src/Examples/MultiDbContextExample/Data/DbContextA.cs index b21e69f2ff..32f6197600 100644 --- a/src/Examples/MultiDbContextExample/Data/DbContextA.cs +++ b/src/Examples/MultiDbContextExample/Data/DbContextA.cs @@ -5,12 +5,8 @@ namespace MultiDbContextExample.Data; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class DbContextA : DbContext +public sealed class DbContextA(DbContextOptions options) + : DbContext(options) { public DbSet ResourceAs => Set(); - - public DbContextA(DbContextOptions options) - : base(options) - { - } } diff --git a/src/Examples/MultiDbContextExample/Data/DbContextB.cs b/src/Examples/MultiDbContextExample/Data/DbContextB.cs index 9bc82c5257..8759e28e91 100644 --- a/src/Examples/MultiDbContextExample/Data/DbContextB.cs +++ b/src/Examples/MultiDbContextExample/Data/DbContextB.cs @@ -5,12 +5,8 @@ namespace MultiDbContextExample.Data; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class DbContextB : DbContext +public sealed class DbContextB(DbContextOptions options) + : DbContext(options) { public DbSet ResourceBs => Set(); - - public DbContextB(DbContextOptions options) - : base(options) - { - } } diff --git a/src/Examples/MultiDbContextExample/MultiDbContextExample.csproj b/src/Examples/MultiDbContextExample/MultiDbContextExample.csproj index ab152b79d5..611aeb37a5 100644 --- a/src/Examples/MultiDbContextExample/MultiDbContextExample.csproj +++ b/src/Examples/MultiDbContextExample/MultiDbContextExample.csproj @@ -1,8 +1,10 @@ - $(TargetFrameworkName) + net9.0;net8.0 + + - + diff --git a/src/Examples/MultiDbContextExample/Program.cs b/src/Examples/MultiDbContextExample/Program.cs index f8a99654de..481e8f7118 100644 --- a/src/Examples/MultiDbContextExample/Program.cs +++ b/src/Examples/MultiDbContextExample/Program.cs @@ -1,4 +1,7 @@ +using System.Diagnostics; using JsonApiDotNetCore.Configuration; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using MultiDbContextExample.Data; using MultiDbContextExample.Models; using MultiDbContextExample.Repositories; @@ -7,22 +10,38 @@ // Add services to the container. -builder.Services.AddSqlite("Data Source=A.db;Pooling=False"); -builder.Services.AddSqlite("Data Source=B.db;Pooling=False"); +builder.Services.AddDbContext(options => +{ + options.UseSqlite("Data Source=SampleDbA.db;Pooling=False"); + SetDbContextDebugOptions(options); +}); + +builder.Services.AddDbContext(options => +{ + options.UseSqlite("Data Source=SampleDbB.db;Pooling=False"); + SetDbContextDebugOptions(options); +}); + +builder.Services.AddResourceRepository>(); +builder.Services.AddResourceRepository>(); builder.Services.AddJsonApi(options => { + options.Namespace = "api"; + options.UseRelativeLinks = true; + options.IncludeTotalResourceCount = true; + +#if DEBUG options.IncludeExceptionStackTraceInErrors = true; options.IncludeRequestBodyInErrors = true; + options.SerializerOptions.WriteIndented = true; +#endif }, dbContextTypes: new[] { typeof(DbContextA), typeof(DbContextB) }); -builder.Services.AddResourceRepository>(); -builder.Services.AddResourceRepository>(); - WebApplication app = builder.Build(); // Configure the HTTP request pipeline. @@ -33,7 +52,15 @@ await CreateDatabaseAsync(app.Services); -app.Run(); +await app.RunAsync(); + +[Conditional("DEBUG")] +static void SetDbContextDebugOptions(DbContextOptionsBuilder options) +{ + options.EnableDetailedErrors(); + options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(builder => builder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); +} static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) { diff --git a/src/Examples/MultiDbContextExample/Properties/launchSettings.json b/src/Examples/MultiDbContextExample/Properties/launchSettings.json index a77f78562b..2cb2d59cae 100644 --- a/src/Examples/MultiDbContextExample/Properties/launchSettings.json +++ b/src/Examples/MultiDbContextExample/Properties/launchSettings.json @@ -1,5 +1,5 @@ { - "$schema": "http://json.schemastore.org/launchsettings.json", + "$schema": "https://json.schemastore.org/launchsettings.json", "iisSettings": { "windowsAuthentication": false, "anonymousAuthentication": true, @@ -12,7 +12,7 @@ "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, - "launchUrl": "resourceBs", + "launchUrl": "api/resourceBs", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -20,7 +20,7 @@ "Kestrel": { "commandName": "Project", "launchBrowser": true, - "launchUrl": "resourceBs", + "launchUrl": "api/resourceBs", "applicationUrl": "https://localhost:44350;http://localhost:14150", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs b/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs index aadeb889cc..d90b572004 100644 --- a/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs +++ b/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs @@ -8,13 +8,9 @@ namespace MultiDbContextExample.Repositories; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class DbContextARepository : EntityFrameworkCoreRepository - where TResource : class, IIdentifiable -{ - public DbContextARepository(ITargetedFields targetedFields, DbContextResolver dbContextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, - IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) - { - } -} +public sealed class DbContextARepository( + ITargetedFields targetedFields, DbContextResolver dbContextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, + IEnumerable constraintProviders, ILoggerFactory loggerFactory, IResourceDefinitionAccessor resourceDefinitionAccessor) + : EntityFrameworkCoreRepository(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, + resourceDefinitionAccessor) + where TResource : class, IIdentifiable; diff --git a/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs b/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs index ac4ce8789c..ed56237d56 100644 --- a/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs +++ b/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs @@ -8,13 +8,9 @@ namespace MultiDbContextExample.Repositories; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class DbContextBRepository : EntityFrameworkCoreRepository - where TResource : class, IIdentifiable -{ - public DbContextBRepository(ITargetedFields targetedFields, DbContextResolver dbContextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, - IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) - { - } -} +public sealed class DbContextBRepository( + ITargetedFields targetedFields, DbContextResolver dbContextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, + IEnumerable constraintProviders, ILoggerFactory loggerFactory, IResourceDefinitionAccessor resourceDefinitionAccessor) + : EntityFrameworkCoreRepository(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, + resourceDefinitionAccessor) + where TResource : class, IIdentifiable; diff --git a/src/Examples/MultiDbContextExample/appsettings.json b/src/Examples/MultiDbContextExample/appsettings.json index d0229a3016..590851ee61 100644 --- a/src/Examples/MultiDbContextExample/appsettings.json +++ b/src/Examples/MultiDbContextExample/appsettings.json @@ -2,8 +2,10 @@ "Logging": { "LogLevel": { "Default": "Warning", - "Microsoft.Hosting.Lifetime": "Warning", - "Microsoft.EntityFrameworkCore.Database.Command": "Critical" + // Include server startup, incoming requests and SQL commands. + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore.Hosting.Diagnostics": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information" } }, "AllowedHosts": "*" diff --git a/src/Examples/NoEntityFrameworkExample/Data/AppDbContext.cs b/src/Examples/NoEntityFrameworkExample/Data/AppDbContext.cs deleted file mode 100644 index c10cda8e6c..0000000000 --- a/src/Examples/NoEntityFrameworkExample/Data/AppDbContext.cs +++ /dev/null @@ -1,16 +0,0 @@ -using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore; -using NoEntityFrameworkExample.Models; - -namespace NoEntityFrameworkExample.Data; - -[UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class AppDbContext : DbContext -{ - public DbSet WorkItems => Set(); - - public AppDbContext(DbContextOptions options) - : base(options) - { - } -} diff --git a/src/Examples/NoEntityFrameworkExample/Data/Database.cs b/src/Examples/NoEntityFrameworkExample/Data/Database.cs new file mode 100644 index 0000000000..5d0c00eb17 --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Data/Database.cs @@ -0,0 +1,131 @@ +using JetBrains.Annotations; +using NoEntityFrameworkExample.Models; + +namespace NoEntityFrameworkExample.Data; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +internal sealed class Database +{ + public static List TodoItems { get; } + public static List Tags { get; } + public static List People { get; } + + static Database() + { + int personIndex = 0; + int tagIndex = 0; + int todoItemIndex = 0; + + var john = new Person + { + Id = ++personIndex, + FirstName = "John", + LastName = "Doe" + }; + + var jane = new Person + { + Id = ++personIndex, + FirstName = "Jane", + LastName = "Doe" + }; + + var personalTag = new Tag + { + Id = ++tagIndex, + Name = "Personal" + }; + + var familyTag = new Tag + { + Id = ++tagIndex, + Name = "Family" + }; + + var businessTag = new Tag + { + Id = ++tagIndex, + Name = "Business" + }; + + TodoItems = + [ + new TodoItem + { + Id = ++todoItemIndex, + Description = "Make homework", + DurationInHours = 3, + Priority = TodoItemPriority.High, + Owner = john, + Assignee = jane, + Tags = + { + personalTag + } + }, + new TodoItem + { + Id = ++todoItemIndex, + Description = "Book vacation", + DurationInHours = 2, + Priority = TodoItemPriority.Low, + Owner = jane, + Tags = + { + personalTag + } + }, + new TodoItem + { + Id = ++todoItemIndex, + Description = "Cook dinner", + DurationInHours = 1, + Priority = TodoItemPriority.Medium, + Owner = jane, + Assignee = john, + Tags = + { + familyTag, + personalTag + } + }, + new TodoItem + { + Id = ++todoItemIndex, + Description = "Check emails", + DurationInHours = 1, + Priority = TodoItemPriority.Low, + Owner = john, + Assignee = john, + Tags = + { + businessTag + } + } + ]; + + Tags = + [ + personalTag, + familyTag, + businessTag + ]; + + People = + [ + john, + jane + ]; + + foreach (Tag tag in Tags) + { + tag.TodoItems = TodoItems.Where(todoItem => todoItem.Tags.Any(tagInTodoItem => tagInTodoItem.Id == tag.Id)).ToHashSet(); + } + + foreach (Person person in People) + { + person.OwnedTodoItems = TodoItems.Where(todoItem => todoItem.Owner == person).ToHashSet(); + person.AssignedTodoItems = TodoItems.Where(todoItem => todoItem.Assignee == person).ToHashSet(); + } + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Data/ResourceGraphExtensions.cs b/src/Examples/NoEntityFrameworkExample/Data/ResourceGraphExtensions.cs new file mode 100644 index 0000000000..ff35f0ab0d --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Data/ResourceGraphExtensions.cs @@ -0,0 +1,32 @@ +using System.Reflection; +using JsonApiDotNetCore.Configuration; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace NoEntityFrameworkExample.Data; + +internal static class ResourceGraphExtensions +{ + public static IReadOnlyModel ToEntityModel(this IResourceGraph resourceGraph) + { + var modelBuilder = new ModelBuilder(); + + foreach (ResourceType resourceType in resourceGraph.GetResourceTypes()) + { + IncludeResourceType(resourceType, modelBuilder); + } + + return modelBuilder.Model; + } + + private static void IncludeResourceType(ResourceType resourceType, ModelBuilder builder) + { + EntityTypeBuilder entityTypeBuilder = builder.Entity(resourceType.ClrType); + + foreach (PropertyInfo property in resourceType.ClrType.GetProperties()) + { + entityTypeBuilder.Property(property.PropertyType, property.Name); + } + } +} diff --git a/src/Examples/NoEntityFrameworkExample/InMemoryInverseNavigationResolver.cs b/src/Examples/NoEntityFrameworkExample/InMemoryInverseNavigationResolver.cs new file mode 100644 index 0000000000..5e0d6e7bd7 --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/InMemoryInverseNavigationResolver.cs @@ -0,0 +1,36 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; +using NoEntityFrameworkExample.Models; + +namespace NoEntityFrameworkExample; + +internal sealed class InMemoryInverseNavigationResolver(IResourceGraph resourceGraph) : IInverseNavigationResolver +{ + private readonly IResourceGraph _resourceGraph = resourceGraph; + + /// + public void Resolve() + { + ResourceType todoItemType = _resourceGraph.GetResourceType(); + RelationshipAttribute todoItemOwnerRelationship = todoItemType.GetRelationshipByPropertyName(nameof(TodoItem.Owner)); + RelationshipAttribute todoItemAssigneeRelationship = todoItemType.GetRelationshipByPropertyName(nameof(TodoItem.Assignee)); + RelationshipAttribute todoItemTagsRelationship = todoItemType.GetRelationshipByPropertyName(nameof(TodoItem.Tags)); + + ResourceType personType = _resourceGraph.GetResourceType(); + RelationshipAttribute personOwnedTodoItemsRelationship = personType.GetRelationshipByPropertyName(nameof(Person.OwnedTodoItems)); + RelationshipAttribute personAssignedTodoItemsRelationship = personType.GetRelationshipByPropertyName(nameof(Person.AssignedTodoItems)); + + ResourceType tagType = _resourceGraph.GetResourceType(); + RelationshipAttribute tagTodoItemsRelationship = tagType.GetRelationshipByPropertyName(nameof(Tag.TodoItems)); + + // Inverse navigations are required for pagination on non-primary endpoints. + todoItemOwnerRelationship.InverseNavigationProperty = personOwnedTodoItemsRelationship.Property; + todoItemAssigneeRelationship.InverseNavigationProperty = personAssignedTodoItemsRelationship.Property; + todoItemTagsRelationship.InverseNavigationProperty = tagTodoItemsRelationship.Property; + + personOwnedTodoItemsRelationship.InverseNavigationProperty = todoItemOwnerRelationship.Property; + personAssignedTodoItemsRelationship.InverseNavigationProperty = todoItemAssigneeRelationship.Property; + + tagTodoItemsRelationship.InverseNavigationProperty = todoItemTagsRelationship.Property; + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Models/Person.cs b/src/Examples/NoEntityFrameworkExample/Models/Person.cs new file mode 100644 index 0000000000..2e7d4a02ab --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Models/Person.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations.Schema; +using JetBrains.Annotations; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace NoEntityFrameworkExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(GenerateControllerEndpoints = JsonApiEndpoints.Query)] +public sealed class Person : Identifiable +{ + [Attr] + public string? FirstName { get; set; } + + [Attr] + public string LastName { get; set; } = null!; + + [Attr(Capabilities = AttrCapabilities.AllowView)] + [NotMapped] + public string DisplayName => FirstName != null ? $"{FirstName} {LastName}" : LastName; + + [HasMany] + public ISet OwnedTodoItems { get; set; } = new HashSet(); + + [HasMany] + public ISet AssignedTodoItems { get; set; } = new HashSet(); +} diff --git a/src/Examples/NoEntityFrameworkExample/Models/Tag.cs b/src/Examples/NoEntityFrameworkExample/Models/Tag.cs new file mode 100644 index 0000000000..4a6ae70f49 --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Models/Tag.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace NoEntityFrameworkExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(GenerateControllerEndpoints = JsonApiEndpoints.Query)] +public sealed class Tag : Identifiable +{ + [Attr] + [MinLength(1)] + public string Name { get; set; } = null!; + + [HasMany] + public ISet TodoItems { get; set; } = new HashSet(); +} diff --git a/src/Examples/NoEntityFrameworkExample/Models/TodoItem.cs b/src/Examples/NoEntityFrameworkExample/Models/TodoItem.cs new file mode 100644 index 0000000000..75d948ca7c --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Models/TodoItem.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace NoEntityFrameworkExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(GenerateControllerEndpoints = JsonApiEndpoints.Query)] +public sealed class TodoItem : Identifiable +{ + [Attr] + public string Description { get; set; } = null!; + + [Attr] + [Required] + public TodoItemPriority? Priority { get; set; } + + [Attr] + public long? DurationInHours { get; set; } + + [HasOne] + public Person Owner { get; set; } = null!; + + [HasOne] + public Person? Assignee { get; set; } + + [HasMany] + public ISet Tags { get; set; } = new HashSet(); +} diff --git a/src/Examples/NoEntityFrameworkExample/Models/TodoItemPriority.cs b/src/Examples/NoEntityFrameworkExample/Models/TodoItemPriority.cs new file mode 100644 index 0000000000..7dfd01f570 --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Models/TodoItemPriority.cs @@ -0,0 +1,11 @@ +using JetBrains.Annotations; + +namespace NoEntityFrameworkExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public enum TodoItemPriority +{ + High = 1, + Medium = 2, + Low = 3 +} diff --git a/src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs b/src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs deleted file mode 100644 index 43bf1f422e..0000000000 --- a/src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs +++ /dev/null @@ -1,22 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace NoEntityFrameworkExample.Models; - -[UsedImplicitly(ImplicitUseTargetFlags.Members)] -[Resource] -public sealed class WorkItem : Identifiable -{ - [Attr] - public bool IsBlocked { get; set; } - - [Attr] - public string Title { get; set; } = null!; - - [Attr] - public long DurationInHours { get; set; } - - [Attr] - public Guid ProjectId { get; set; } = Guid.NewGuid(); -} diff --git a/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj b/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj index 358d82c02f..15a485c08f 100644 --- a/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj +++ b/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj @@ -1,8 +1,10 @@ - $(TargetFrameworkName) + net9.0;net8.0 + + - - - + diff --git a/src/Examples/NoEntityFrameworkExample/NullSafeExpressionRewriter.cs b/src/Examples/NoEntityFrameworkExample/NullSafeExpressionRewriter.cs new file mode 100644 index 0000000000..61ebb8b8b0 --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/NullSafeExpressionRewriter.cs @@ -0,0 +1,316 @@ +using System.Linq.Expressions; +using System.Reflection; + +namespace NoEntityFrameworkExample; + +/// +/// Inserts a null check on member dereference and extension method invocation, to prevent a from being thrown when +/// the expression is compiled and executed. +/// +/// For example, +/// todoItem.Assignee.Id == todoItem.Owner.Id) +/// ]]> +/// would throw if the database contains a +/// TodoItem that doesn't have an assignee. +/// +public sealed class NullSafeExpressionRewriter : ExpressionVisitor +{ + private const string MinValueName = nameof(long.MinValue); + private static readonly ConstantExpression Int32MinValueConstant = Expression.Constant(int.MinValue, typeof(int)); + + private static readonly ExpressionType[] ComparisonExpressionTypes = + [ + ExpressionType.LessThan, + ExpressionType.LessThanOrEqual, + ExpressionType.GreaterThan, + ExpressionType.GreaterThanOrEqual, + ExpressionType.Equal + // ExpressionType.NotEqual is excluded because WhereClauseBuilder never produces that. + ]; + + private readonly Stack _callStack = new(); + + public TExpression Rewrite(TExpression expression) + where TExpression : Expression + { + _callStack.Clear(); + + return (TExpression)Visit(expression); + } + + protected override Expression VisitMethodCall(MethodCallExpression node) + { + if (node.Method.Name == "Where") + { + _callStack.Push(MethodType.Where); + Expression expression = base.VisitMethodCall(node); + _callStack.Pop(); + return expression; + } + + if (node.Method.Name is "OrderBy" or "OrderByDescending" or "ThenBy" or "ThenByDescending") + { + // Ordering can be improved by expanding into multiple OrderBy/ThenBy() calls, as described at + // https://stackoverflow.com/questions/26186527/linq-order-by-descending-with-null-values-on-bottom/26186585#26186585. + // For example: + // .OrderBy(element => element.First.Second.CharValue) + // Could be translated to: + // .OrderBy(element => element.First != null) + // .ThenBy(element => element.First == null ? false : element.First.Second != null) + // .ThenBy(element => element.First == null ? '\0' : element.First.Second == null ? '\0' : element.First.Second.CharValue) + // Which correctly orders 'element.First == null' before 'element.First.Second == null'. + // The current implementation translates to: + // .OrderBy(element => element.First == null ? '\0' : element.First.Second == null ? '\0' : element.First.Second.CharValue) + // in which the order of these two rows is undeterministic. + + _callStack.Push(MethodType.Ordering); + Expression expression = base.VisitMethodCall(node); + _callStack.Pop(); + return expression; + } + + if (_callStack.Count > 0) + { + MethodType outerMethodType = _callStack.Peek(); + + if (outerMethodType == MethodType.Ordering && node.Method.Name == "Count") + { + return ToNullSafeCountInvocationInOrderBy(node); + } + + if (outerMethodType == MethodType.Where && node.Method.Name == "Any") + { + return ToNullSafeAnyInvocationInWhere(node); + } + } + + return base.VisitMethodCall(node); + } + + private static Expression ToNullSafeCountInvocationInOrderBy(MethodCallExpression countMethodCall) + { + Expression thisArgument = countMethodCall.Arguments.Single(); + + if (thisArgument is MemberExpression memberArgument) + { + // OrderClauseBuilder never produces nested Count() calls. + + // SRC: some.Other.Children.Count() + // DST: some.Other == null ? int.MinValue : some.Other.Children == null ? int.MinValue : some.Other.Children.Count() + return ToConditionalMemberAccessInOrderBy(countMethodCall, memberArgument, Int32MinValueConstant); + } + + return countMethodCall; + } + + private static Expression ToConditionalMemberAccessInOrderBy(Expression outer, MemberExpression innerMember, ConstantExpression defaultValue) + { + MemberExpression? currentMember = innerMember; + Expression result = outer; + + do + { + // Static property/field invocations can never be null (though unlikely we'll ever encounter those). + if (!IsStaticMemberAccess(currentMember)) + { + // SRC: first.Second.StringValue + // DST: first.Second == null ? null : first.Second.StringValue + ConstantExpression nullConstant = Expression.Constant(null, currentMember.Type); + BinaryExpression isNull = Expression.Equal(currentMember, nullConstant); + result = Expression.Condition(isNull, defaultValue, result); + } + + currentMember = currentMember.Expression as MemberExpression; + } + while (currentMember != null); + + return result; + } + + private static bool IsStaticMemberAccess(MemberExpression member) + { + if (member.Member is FieldInfo field) + { + return field.IsStatic; + } + + if (member.Member is PropertyInfo property) + { + MethodInfo? getter = property.GetGetMethod(); + return getter != null && getter.IsStatic; + } + + return false; + } + + private Expression ToNullSafeAnyInvocationInWhere(MethodCallExpression anyMethodCall) + { + Expression thisArgument = anyMethodCall.Arguments.First(); + + if (thisArgument is MemberExpression memberArgument) + { + MethodCallExpression newAnyMethodCall = anyMethodCall; + + if (anyMethodCall.Arguments.Count > 1) + { + // SRC: .Any(first => first.Second.Value == 1) + // DST: .Any(first => first != null && first.Second != null && first.Second.Value == 1) + List newArguments = anyMethodCall.Arguments.Skip(1).Select(Visit).Cast().ToList(); + newArguments.Insert(0, thisArgument); + + newAnyMethodCall = anyMethodCall.Update(anyMethodCall.Object, newArguments); + } + + // SRC: some.Other.Any() + // DST: some != null && some.Other != null && some.Other.Any() + return ToConditionalMemberAccessInBooleanExpression(newAnyMethodCall, memberArgument, false); + } + + return anyMethodCall; + } + + private static Expression ToConditionalMemberAccessInBooleanExpression(Expression outer, MemberExpression innerMember, bool skipNullCheckOnLastAccess) + { + MemberExpression? currentMember = innerMember; + Expression result = outer; + + do + { + // Null-check the last member access in the chain on extension method invocation. For example: a.b.c.Count() requires a null-check on 'c'. + // This is unneeded for boolean comparisons. For example: a.b.c == d does not require a null-check on 'c'. + if (!skipNullCheckOnLastAccess || currentMember != innerMember) + { + // Static property/field invocations can never be null (though unlikely we'll ever encounter those). + if (!IsStaticMemberAccess(currentMember)) + { + // SRC: first.Second.Value == 1 + // DST: first.Second != null && first.Second.Value == 1 + ConstantExpression nullConstant = Expression.Constant(null, currentMember.Type); + BinaryExpression isNotNull = Expression.NotEqual(currentMember, nullConstant); + result = Expression.AndAlso(isNotNull, result); + } + } + + // Do not null-check the first member access in the chain, because that's the lambda parameter itself. + // For example, in: item => item.First.Second, 'item' does not require a null-check. + currentMember = currentMember.Expression as MemberExpression; + } + while (currentMember != null); + + return result; + } + + protected override Expression VisitBinary(BinaryExpression node) + { + if (_callStack.Count > 0 && _callStack.Peek() == MethodType.Where) + { + if (ComparisonExpressionTypes.Contains(node.NodeType)) + { + Expression result = node; + + result = ToNullSafeTermInBinary(node.Right, result); + result = ToNullSafeTermInBinary(node.Left, result); + + return result; + } + } + + return base.VisitBinary(node); + } + + private static Expression ToNullSafeTermInBinary(Expression binaryTerm, Expression result) + { + if (binaryTerm is MemberExpression rightMember) + { + // SRC: some.Other.Value == 1 + // DST: some != null && some.Other != null && some.Other.Value == 1 + return ToConditionalMemberAccessInBooleanExpression(result, rightMember, true); + } + + if (binaryTerm is MethodCallExpression { Method.Name: "Count" } countMethodCall) + { + Expression thisArgument = countMethodCall.Arguments.Single(); + + if (thisArgument is MemberExpression memberArgument) + { + // SRC: some.Other.Count() == 1 + // DST: some != null && some.Other != null && some.Other.Count() == 1 + return ToConditionalMemberAccessInBooleanExpression(result, memberArgument, false); + } + } + + return result; + } + + protected override Expression VisitMember(MemberExpression node) + { + if (_callStack.Count > 0 && _callStack.Peek() == MethodType.Ordering) + { + if (node.Expression is MemberExpression innerMember) + { + ConstantExpression defaultValue = CreateConstantForMemberIsNull(node.Type); + return ToConditionalMemberAccessInOrderBy(node, innerMember, defaultValue); + } + + return node; + } + + return base.VisitMember(node); + } + + private static ConstantExpression CreateConstantForMemberIsNull(Type type) + { + bool canContainNull = !type.IsValueType || Nullable.GetUnderlyingType(type) != null; + + if (canContainNull) + { + return Expression.Constant(null, type); + } + + Type innerType = Nullable.GetUnderlyingType(type) ?? type; + ConstantExpression? constant = TryCreateConstantForStaticMinValue(innerType); + + if (constant != null) + { + return constant; + } + + object? defaultValue = Activator.CreateInstance(type); + return Expression.Constant(defaultValue, type); + } + + private static ConstantExpression? TryCreateConstantForStaticMinValue(Type type) + { + // Int32.MinValue is a field, while Int128.MinValue is a property. + + FieldInfo? field = type.GetField(MinValueName, BindingFlags.Public | BindingFlags.Static); + + if (field != null) + { + object? value = field.GetValue(null); + return Expression.Constant(value, type); + } + + PropertyInfo? property = type.GetProperty(MinValueName, BindingFlags.Public | BindingFlags.Static); + + if (property != null) + { + MethodInfo? getter = property.GetGetMethod(); + + if (getter != null) + { + object? value = getter.Invoke(null, []); + return Expression.Constant(value, type); + } + } + + return null; + } + + private enum MethodType + { + Where, + Ordering + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Program.cs b/src/Examples/NoEntityFrameworkExample/Program.cs index 43a14f7896..8eff35d7a9 100755 --- a/src/Examples/NoEntityFrameworkExample/Program.cs +++ b/src/Examples/NoEntityFrameworkExample/Program.cs @@ -1,18 +1,31 @@ using JsonApiDotNetCore.Configuration; +using NoEntityFrameworkExample; using NoEntityFrameworkExample.Data; -using NoEntityFrameworkExample.Models; -using NoEntityFrameworkExample.Services; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); // Add services to the container. -string connectionString = GetConnectionString(builder.Configuration); -builder.Services.AddNpgsql(connectionString); +builder.Services.AddScoped(); -builder.Services.AddJsonApi(options => options.Namespace = "api/v1", resources: resourceGraphBuilder => resourceGraphBuilder.Add()); - -builder.Services.AddResourceService(); +builder.Services.AddJsonApi(options => +{ + options.Namespace = "api"; + options.UseRelativeLinks = true; + options.IncludeTotalResourceCount = true; + +#if DEBUG + options.IncludeExceptionStackTraceInErrors = true; + options.IncludeRequestBodyInErrors = true; + options.SerializerOptions.WriteIndented = true; +#endif +}, discovery => discovery.AddCurrentAssembly()); + +builder.Services.AddSingleton(serviceProvider => +{ + var resourceGraph = serviceProvider.GetRequiredService(); + return resourceGraph.ToEntityModel(); +}); WebApplication app = builder.Build(); @@ -22,20 +35,4 @@ app.UseJsonApi(); app.MapControllers(); -await CreateDatabaseAsync(app.Services); - -app.Run(); - -static string GetConnectionString(IConfiguration configuration) -{ - string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - return configuration["Data:DefaultConnection"].Replace("###", postgresPassword); -} - -static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) -{ - await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); - - var dbContext = scope.ServiceProvider.GetRequiredService(); - await dbContext.Database.EnsureCreatedAsync(); -} +await app.RunAsync(); diff --git a/src/Examples/NoEntityFrameworkExample/Properties/launchSettings.json b/src/Examples/NoEntityFrameworkExample/Properties/launchSettings.json index 82c88ace03..e5d1b8837c 100644 --- a/src/Examples/NoEntityFrameworkExample/Properties/launchSettings.json +++ b/src/Examples/NoEntityFrameworkExample/Properties/launchSettings.json @@ -1,5 +1,5 @@ { - "$schema": "http://json.schemastore.org/launchsettings.json", + "$schema": "https://json.schemastore.org/launchsettings.json", "iisSettings": { "windowsAuthentication": false, "anonymousAuthentication": true, @@ -12,7 +12,7 @@ "IIS Express": { "commandName": "IISExpress", "launchBrowser": true, - "launchUrl": "api/v1/workItems", + "launchUrl": "api/todoItems?include=owner,assignee,tags&filter=equals(priority,'High')", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } @@ -20,7 +20,7 @@ "Kestrel": { "commandName": "Project", "launchBrowser": true, - "launchUrl": "api/v1/workItems", + "launchUrl": "api/todoItems?include=owner,assignee,tags&filter=equals(priority,'High')", "applicationUrl": "https://localhost:44349;http://localhost:14149", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/src/Examples/NoEntityFrameworkExample/QueryLayerToLinqConverter.cs b/src/Examples/NoEntityFrameworkExample/QueryLayerToLinqConverter.cs new file mode 100644 index 0000000000..a67a694aef --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/QueryLayerToLinqConverter.cs @@ -0,0 +1,37 @@ +using System.Collections; +using System.Linq.Expressions; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.QueryableBuilding; +using JsonApiDotNetCore.Resources; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace NoEntityFrameworkExample; + +internal sealed class QueryLayerToLinqConverter(IReadOnlyModel entityModel, IQueryableBuilder queryableBuilder) +{ + private readonly IReadOnlyModel _entityModel = entityModel; + private readonly IQueryableBuilder _queryableBuilder = queryableBuilder; + + public IEnumerable ApplyQueryLayer(QueryLayer queryLayer, IEnumerable resources) + where TResource : class, IIdentifiable + { + // The Include() extension method from Entity Framework Core is unavailable, so rewrite into selectors. + var converter = new QueryLayerIncludeConverter(queryLayer); + converter.ConvertIncludesToSelections(); + + // Convert QueryLayer into LINQ expression. + IQueryable source = ((IEnumerable)resources).AsQueryable(); + var context = QueryableBuilderContext.CreateRoot(source, typeof(Enumerable), _entityModel, null); + Expression expression = _queryableBuilder.ApplyQuery(queryLayer, context); + + // Insert null checks to prevent a NullReferenceException during execution of expressions such as: + // 'todoItems => todoItems.Where(todoItem => todoItem.Assignee.Id == 1)' when a TodoItem doesn't have an assignee. + NullSafeExpressionRewriter rewriter = new(); + expression = rewriter.Rewrite(expression); + + // Compile and execute LINQ expression against the in-memory database. + Delegate function = Expression.Lambda(expression).Compile(); + object result = function.DynamicInvoke()!; + return (IEnumerable)result; + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs b/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs new file mode 100644 index 0000000000..4feb370858 --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs @@ -0,0 +1,53 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.QueryableBuilding; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace NoEntityFrameworkExample.Repositories; + +/// +/// Demonstrates how to replace the built-in . This read-only repository uses the built-in +/// to convert the incoming into a LINQ expression, then compiles and executes it against the +/// in-memory database. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +public abstract class InMemoryResourceRepository(IResourceGraph resourceGraph, IQueryableBuilder queryableBuilder, IReadOnlyModel entityModel) + : IResourceReadRepository + where TResource : class, IIdentifiable +{ + private readonly ResourceType _resourceType = resourceGraph.GetResourceType(); + private readonly QueryLayerToLinqConverter _queryLayerToLinqConverter = new(entityModel, queryableBuilder); + + /// + public Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + { + IEnumerable dataSource = GetDataSource(); + IEnumerable resources = _queryLayerToLinqConverter.ApplyQueryLayer(queryLayer, dataSource); + + return Task.FromResult>(resources.ToArray().AsReadOnly()); + } + + /// + public Task CountAsync(FilterExpression? filter, CancellationToken cancellationToken) + { + var queryLayer = new QueryLayer(_resourceType) + { + Filter = filter + }; + + IEnumerable dataSource = GetDataSource(); + IEnumerable resources = _queryLayerToLinqConverter.ApplyQueryLayer(queryLayer, dataSource); + + return Task.FromResult(resources.Count()); + } + + protected abstract IEnumerable GetDataSource(); +} diff --git a/src/Examples/NoEntityFrameworkExample/Repositories/PersonRepository.cs b/src/Examples/NoEntityFrameworkExample/Repositories/PersonRepository.cs new file mode 100644 index 0000000000..8e2725379c --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Repositories/PersonRepository.cs @@ -0,0 +1,18 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.QueryableBuilding; +using Microsoft.EntityFrameworkCore.Metadata; +using NoEntityFrameworkExample.Data; +using NoEntityFrameworkExample.Models; + +namespace NoEntityFrameworkExample.Repositories; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class PersonRepository(IResourceGraph resourceGraph, IQueryableBuilder queryableBuilder, IReadOnlyModel entityModel) + : InMemoryResourceRepository(resourceGraph, queryableBuilder, entityModel) +{ + protected override IEnumerable GetDataSource() + { + return Database.People; + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Repositories/TagRepository.cs b/src/Examples/NoEntityFrameworkExample/Repositories/TagRepository.cs new file mode 100644 index 0000000000..81a28ed6bc --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Repositories/TagRepository.cs @@ -0,0 +1,18 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.QueryableBuilding; +using Microsoft.EntityFrameworkCore.Metadata; +using NoEntityFrameworkExample.Data; +using NoEntityFrameworkExample.Models; + +namespace NoEntityFrameworkExample.Repositories; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class TagRepository(IResourceGraph resourceGraph, IQueryableBuilder queryableBuilder, IReadOnlyModel entityModel) + : InMemoryResourceRepository(resourceGraph, queryableBuilder, entityModel) +{ + protected override IEnumerable GetDataSource() + { + return Database.Tags; + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Repositories/TodoItemRepository.cs b/src/Examples/NoEntityFrameworkExample/Repositories/TodoItemRepository.cs new file mode 100644 index 0000000000..335d7c5c5a --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Repositories/TodoItemRepository.cs @@ -0,0 +1,18 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.QueryableBuilding; +using Microsoft.EntityFrameworkCore.Metadata; +using NoEntityFrameworkExample.Data; +using NoEntityFrameworkExample.Models; + +namespace NoEntityFrameworkExample.Repositories; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class TodoItemRepository(IResourceGraph resourceGraph, IQueryableBuilder queryableBuilder, IReadOnlyModel entityModel) + : InMemoryResourceRepository(resourceGraph, queryableBuilder, entityModel) +{ + protected override IEnumerable GetDataSource() + { + return Database.TodoItems; + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs b/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs new file mode 100644 index 0000000000..0dcc5d9905 --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs @@ -0,0 +1,201 @@ +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.QueryableBuilding; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Services; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace NoEntityFrameworkExample.Services; + +/// +/// Demonstrates how to replace the built-in . This read-only resource service uses the built-in +/// to convert the incoming query string parameters into a , then uses the built-in +/// to convert the into a LINQ expression, then compiles and executes it against the in-memory +/// database. +/// +/// +/// +/// This resource service is a simplified version of the built-in resource service. Instead of implementing a resource service, consider implementing a +/// resource repository, which only needs to provide data access. +/// +/// The incoming filter from query string is logged, just to show how you can access it directly. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +public abstract partial class InMemoryResourceService( + IJsonApiOptions options, IResourceGraph resourceGraph, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, + IEnumerable constraintProviders, IQueryableBuilder queryableBuilder, IReadOnlyModel entityModel, + ILoggerFactory loggerFactory) : IResourceQueryService + where TResource : class, IIdentifiable +{ + private readonly IJsonApiOptions _options = options; + private readonly IQueryLayerComposer _queryLayerComposer = queryLayerComposer; + private readonly IPaginationContext _paginationContext = paginationContext; + private readonly IQueryConstraintProvider[] _constraintProviders = constraintProviders as IQueryConstraintProvider[] ?? constraintProviders.ToArray(); + private readonly ILogger> _logger = loggerFactory.CreateLogger>(); + private readonly ResourceType _resourceType = resourceGraph.GetResourceType(); + private readonly QueryLayerToLinqConverter _queryLayerToLinqConverter = new(entityModel, queryableBuilder); + + /// + public Task> GetAsync(CancellationToken cancellationToken) + { + LogFiltersInTopScope(); + + if (SetPrimaryTotalCountIsZero()) + { + return Task.FromResult>(Array.Empty()); + } + + QueryLayer queryLayer = _queryLayerComposer.ComposeFromConstraints(_resourceType); + + IEnumerable dataSource = GetDataSource(_resourceType).Cast(); + TResource[] resources = _queryLayerToLinqConverter.ApplyQueryLayer(queryLayer, dataSource).ToArray(); + + if (queryLayer.Pagination?.PageSize?.Value == resources.Length) + { + _paginationContext.IsPageFull = true; + } + + return Task.FromResult>(resources.AsReadOnly()); + } + + private void LogFiltersInTopScope() + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:wrap_before_first_method_call true + + FilterExpression[] filtersInTopScope = _constraintProviders + .SelectMany(provider => provider.GetConstraints()) + .Where(constraint => constraint.Scope == null) + .Select(constraint => constraint.Expression) + .OfType() + .ToArray(); + + // @formatter:wrap_before_first_method_call restore + // @formatter:wrap_chained_method_calls restore + + FilterExpression? filter = LogicalExpression.Compose(LogicalOperator.And, filtersInTopScope); + + if (filter != null) + { + LogIncomingFilter(filter); + } + } + + private bool SetPrimaryTotalCountIsZero() + { + if (_options.IncludeTotalResourceCount) + { + var queryLayer = new QueryLayer(_resourceType) + { + Filter = _queryLayerComposer.GetPrimaryFilterFromConstraints(_resourceType) + }; + + IEnumerable dataSource = GetDataSource(_resourceType).Cast(); + IEnumerable resources = _queryLayerToLinqConverter.ApplyQueryLayer(queryLayer, dataSource); + + _paginationContext.TotalResourceCount = resources.Count(); + + if (_paginationContext.TotalResourceCount == 0) + { + return true; + } + } + + return false; + } + + /// + public Task GetAsync([DisallowNull] TId id, CancellationToken cancellationToken) + { + QueryLayer queryLayer = _queryLayerComposer.ComposeForGetById(id, _resourceType, TopFieldSelection.PreserveExisting); + + IEnumerable dataSource = GetDataSource(_resourceType).Cast(); + IEnumerable resources = _queryLayerToLinqConverter.ApplyQueryLayer(queryLayer, dataSource); + TResource? resource = resources.SingleOrDefault(); + + if (resource == null) + { + throw new ResourceNotFoundException(id.ToString()!, _resourceType.PublicName); + } + + return Task.FromResult(resource); + } + + /// + public Task GetSecondaryAsync([DisallowNull] TId id, string relationshipName, CancellationToken cancellationToken) + { + RelationshipAttribute? relationship = _resourceType.FindRelationshipByPublicName(relationshipName); + + if (relationship == null) + { + throw new RelationshipNotFoundException(relationshipName, _resourceType.PublicName); + } + + SetNonPrimaryTotalCount(id, relationship); + + QueryLayer secondaryLayer = _queryLayerComposer.ComposeFromConstraints(relationship.RightType); + QueryLayer primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _resourceType, id, relationship); + + IEnumerable dataSource = GetDataSource(_resourceType).Cast(); + IEnumerable primaryResources = _queryLayerToLinqConverter.ApplyQueryLayer(primaryLayer, dataSource); + TResource? primaryResource = primaryResources.SingleOrDefault(); + + if (primaryResource == null) + { + throw new ResourceNotFoundException(id.ToString()!, _resourceType.PublicName); + } + + object? rightValue = relationship.GetValue(primaryResource); + + if (rightValue is ICollection rightResources && secondaryLayer.Pagination?.PageSize?.Value == rightResources.Count) + { + _paginationContext.IsPageFull = true; + } + + return Task.FromResult(rightValue); + } + + private void SetNonPrimaryTotalCount([DisallowNull] TId id, RelationshipAttribute relationship) + { + if (_options.IncludeTotalResourceCount && relationship is HasManyAttribute hasManyRelationship) + { + FilterExpression? secondaryFilter = _queryLayerComposer.GetSecondaryFilterFromConstraints(id, hasManyRelationship); + + if (secondaryFilter == null) + { + return; + } + + var queryLayer = new QueryLayer(hasManyRelationship.RightType) + { + Filter = secondaryFilter + }; + + IEnumerable dataSource = GetDataSource(hasManyRelationship.RightType); + IEnumerable resources = _queryLayerToLinqConverter.ApplyQueryLayer(queryLayer, dataSource); + + _paginationContext.TotalResourceCount = resources.Count(); + } + } + + /// + public Task GetRelationshipAsync([DisallowNull] TId id, string relationshipName, CancellationToken cancellationToken) + { + return GetSecondaryAsync(id, relationshipName, cancellationToken); + } + + protected abstract IEnumerable GetDataSource(ResourceType resourceType); + + [LoggerMessage(Level = LogLevel.Information, Message = "Incoming top-level filter from query string: {Filter}")] + private partial void LogIncomingFilter(FilterExpression filter); +} diff --git a/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs new file mode 100644 index 0000000000..d38cca9c94 --- /dev/null +++ b/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs @@ -0,0 +1,38 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.QueryableBuilding; +using JsonApiDotNetCore.Resources; +using Microsoft.EntityFrameworkCore.Metadata; +using NoEntityFrameworkExample.Data; +using NoEntityFrameworkExample.Models; + +namespace NoEntityFrameworkExample.Services; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class TodoItemService( + IJsonApiOptions options, IResourceGraph resourceGraph, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, + IEnumerable constraintProviders, IQueryableBuilder queryableBuilder, IReadOnlyModel entityModel, ILoggerFactory loggerFactory) + : InMemoryResourceService(options, resourceGraph, queryLayerComposer, paginationContext, constraintProviders, queryableBuilder, entityModel, + loggerFactory) +{ + protected override IEnumerable GetDataSource(ResourceType resourceType) + { + if (resourceType.ClrType == typeof(TodoItem)) + { + return Database.TodoItems; + } + + if (resourceType.ClrType == typeof(Person)) + { + return Database.People; + } + + if (resourceType.ClrType == typeof(Tag)) + { + return Database.Tags; + } + + throw new InvalidOperationException($"Unknown data source '{resourceType.ClrType}'."); + } +} diff --git a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs deleted file mode 100644 index 6df109e5ba..0000000000 --- a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs +++ /dev/null @@ -1,109 +0,0 @@ -using System.Data; -using Dapper; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Services; -using NoEntityFrameworkExample.Models; -using Npgsql; - -namespace NoEntityFrameworkExample.Services; - -[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class WorkItemService : IResourceService -{ - private readonly string _connectionString; - - public WorkItemService(IConfiguration configuration) - { - string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - _connectionString = configuration["Data:DefaultConnection"].Replace("###", postgresPassword); - } - - public async Task> GetAsync(CancellationToken cancellationToken) - { - const string commandText = @"select * from ""WorkItems"""; - var commandDefinition = new CommandDefinition(commandText, cancellationToken: cancellationToken); - - return await QueryAsync(async connection => await connection.QueryAsync(commandDefinition)); - } - - public async Task GetAsync(int id, CancellationToken cancellationToken) - { - const string commandText = @"select * from ""WorkItems"" where ""Id""=@id"; - - var commandDefinition = new CommandDefinition(commandText, new - { - id - }, cancellationToken: cancellationToken); - - IReadOnlyCollection workItems = await QueryAsync(async connection => await connection.QueryAsync(commandDefinition)); - return workItems.Single(); - } - - public Task GetSecondaryAsync(int id, string relationshipName, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task GetRelationshipAsync(int id, string relationshipName, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public async Task CreateAsync(WorkItem resource, CancellationToken cancellationToken) - { - const string commandText = @"insert into ""WorkItems"" (""Title"", ""IsBlocked"", ""DurationInHours"", ""ProjectId"") values " + - @"(@title, @isBlocked, @durationInHours, @projectId) returning ""Id"", ""Title"", ""IsBlocked"", ""DurationInHours"", ""ProjectId"""; - - var commandDefinition = new CommandDefinition(commandText, new - { - title = resource.Title, - isBlocked = resource.IsBlocked, - durationInHours = resource.DurationInHours, - projectId = resource.ProjectId - }, cancellationToken: cancellationToken); - - IReadOnlyCollection workItems = await QueryAsync(async connection => await connection.QueryAsync(commandDefinition)); - return workItems.Single(); - } - - public Task AddToToManyRelationshipAsync(int leftId, string relationshipName, ISet rightResourceIds, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task UpdateAsync(int id, WorkItem resource, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task SetRelationshipAsync(int leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public async Task DeleteAsync(int id, CancellationToken cancellationToken) - { - const string commandText = @"delete from ""WorkItems"" where ""Id""=@id"; - - await QueryAsync(async connection => await connection.QueryAsync(new CommandDefinition(commandText, new - { - id - }, cancellationToken: cancellationToken))); - } - - public Task RemoveFromToManyRelationshipAsync(int leftId, string relationshipName, ISet rightResourceIds, - CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - private async Task> QueryAsync(Func>> query) - { - using IDbConnection dbConnection = new NpgsqlConnection(_connectionString); - dbConnection.Open(); - - IEnumerable resources = await query(dbConnection); - return resources.ToList(); - } -} diff --git a/src/Examples/NoEntityFrameworkExample/appsettings.json b/src/Examples/NoEntityFrameworkExample/appsettings.json index cea6a7a623..603d1f4f9f 100644 --- a/src/Examples/NoEntityFrameworkExample/appsettings.json +++ b/src/Examples/NoEntityFrameworkExample/appsettings.json @@ -1,12 +1,11 @@ { - "Data": { - "DefaultConnection": "Host=localhost;Port=5432;Database=NoEntityFrameworkExample;User ID=postgres;Password=###" - }, "Logging": { "LogLevel": { "Default": "Warning", - "Microsoft.Hosting.Lifetime": "Warning", - "Microsoft.EntityFrameworkCore.Database.Command": "Critical" + // Include server startup, incoming requests and sample logging. + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.AspNetCore.Hosting.Diagnostics": "Information", + "NoEntityFrameworkExample": "Information" } }, "AllowedHosts": "*" diff --git a/src/Examples/OpenApiKiotaClientExample/ColoredConsoleLogHttpMessageHandler.cs b/src/Examples/OpenApiKiotaClientExample/ColoredConsoleLogHttpMessageHandler.cs new file mode 100644 index 0000000000..e13aff5a4d --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/ColoredConsoleLogHttpMessageHandler.cs @@ -0,0 +1,67 @@ +using JetBrains.Annotations; + +namespace OpenApiKiotaClientExample; + +/// +/// Writes incoming and outgoing HTTP messages to the console. +/// +internal sealed class ColoredConsoleLogHttpMessageHandler : DelegatingHandler +{ + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { +#if DEBUG + await LogRequestAsync(request, cancellationToken); +#endif + + HttpResponseMessage response = await base.SendAsync(request, cancellationToken); + +#if DEBUG + await LogResponseAsync(response, cancellationToken); +#endif + + return response; + } + + [UsedImplicitly] + private static async Task LogRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + using var _ = new ConsoleColorScope(ConsoleColor.Green); + + Console.WriteLine($"--> {request}"); + string? requestBody = request.Content != null ? await request.Content.ReadAsStringAsync(cancellationToken) : null; + + if (!string.IsNullOrEmpty(requestBody)) + { + Console.WriteLine(); + Console.WriteLine(requestBody); + } + } + + [UsedImplicitly] + private static async Task LogResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + using var _ = new ConsoleColorScope(ConsoleColor.Cyan); + + Console.WriteLine($"<-- {response}"); + string responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!string.IsNullOrEmpty(responseBody)) + { + Console.WriteLine(); + Console.WriteLine(responseBody); + } + } + + private sealed class ConsoleColorScope : IDisposable + { + public ConsoleColorScope(ConsoleColor foregroundColor) + { + Console.ForegroundColor = foregroundColor; + } + + public void Dispose() + { + Console.ResetColor(); + } + } +} diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/ApiRequestBuilder.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/ApiRequestBuilder.cs new file mode 100644 index 0000000000..3d0f7361e7 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/ApiRequestBuilder.cs @@ -0,0 +1,66 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions; +using OpenApiKiotaClientExample.GeneratedCode.Api.Operations; +using OpenApiKiotaClientExample.GeneratedCode.Api.People; +using OpenApiKiotaClientExample.GeneratedCode.Api.Tags; +using OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Api +{ + /// + /// Builds and executes requests for operations under \api + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class ApiRequestBuilder : BaseRequestBuilder + { + /// The operations property + public global::OpenApiKiotaClientExample.GeneratedCode.Api.Operations.OperationsRequestBuilder Operations + { + get => new global::OpenApiKiotaClientExample.GeneratedCode.Api.Operations.OperationsRequestBuilder(PathParameters, RequestAdapter); + } + + /// The people property + public global::OpenApiKiotaClientExample.GeneratedCode.Api.People.PeopleRequestBuilder People + { + get => new global::OpenApiKiotaClientExample.GeneratedCode.Api.People.PeopleRequestBuilder(PathParameters, RequestAdapter); + } + + /// The tags property + public global::OpenApiKiotaClientExample.GeneratedCode.Api.Tags.TagsRequestBuilder Tags + { + get => new global::OpenApiKiotaClientExample.GeneratedCode.Api.Tags.TagsRequestBuilder(PathParameters, RequestAdapter); + } + + /// The todoItems property + public global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.TodoItemsRequestBuilder TodoItems + { + get => new global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.TodoItemsRequestBuilder(PathParameters, RequestAdapter); + } + + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public ApiRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api", pathParameters) + { + } + + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public ApiRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api", rawUrl) + { + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/Operations/OperationsRequestBuilder.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/Operations/OperationsRequestBuilder.cs new file mode 100644 index 0000000000..96f21c5a24 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/Operations/OperationsRequestBuilder.cs @@ -0,0 +1,94 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions; +using OpenApiKiotaClientExample.GeneratedCode.Models; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Threading; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Api.Operations +{ + /// + /// Builds and executes requests for operations under \api\operations + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class OperationsRequestBuilder : BaseRequestBuilder + { + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public OperationsRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/operations", pathParameters) + { + } + + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public OperationsRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/operations", rawUrl) + { + } + + /// + /// Performs multiple mutations in a linear and atomic manner. + /// + /// A + /// The request body + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 403 status code + /// When receiving a 404 status code + /// When receiving a 409 status code + /// When receiving a 422 status code + public async Task PostAsync(global::OpenApiKiotaClientExample.GeneratedCode.Models.OperationsRequestDocument body, Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = ToPostRequestInformation(body, requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "403", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "409", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "422", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + return await RequestAdapter.SendAsync(requestInfo, global::OpenApiKiotaClientExample.GeneratedCode.Models.OperationsResponseDocument.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Performs multiple mutations in a linear and atomic manner. + /// + /// A + /// The request body + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToPostRequestInformation(global::OpenApiKiotaClientExample.GeneratedCode.Models.OperationsRequestDocument body, Action>? requestConfiguration = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = new RequestInformation(Method.POST, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=atomic;ext=openapi"); + requestInfo.SetContentFromParsable(RequestAdapter, "application/vnd.api+json;ext=atomic;ext=openapi", body); + return requestInfo; + } + + /// + /// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored. + /// + /// A + /// The raw URL to use for the request builder. + public global::OpenApiKiotaClientExample.GeneratedCode.Api.Operations.OperationsRequestBuilder WithUrl(string rawUrl) + { + return new global::OpenApiKiotaClientExample.GeneratedCode.Api.Operations.OperationsRequestBuilder(rawUrl, RequestAdapter); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/People/Item/AssignedTodoItems/AssignedTodoItemsRequestBuilder.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/People/Item/AssignedTodoItems/AssignedTodoItemsRequestBuilder.cs new file mode 100644 index 0000000000..7bcc782af9 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/People/Item/AssignedTodoItems/AssignedTodoItemsRequestBuilder.cs @@ -0,0 +1,128 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions; +using OpenApiKiotaClientExample.GeneratedCode.Models; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Threading; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Api.People.Item.AssignedTodoItems +{ + /// + /// Builds and executes requests for operations under \api\people\{id}\assignedTodoItems + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class AssignedTodoItemsRequestBuilder : BaseRequestBuilder + { + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public AssignedTodoItemsRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/people/{id}/assignedTodoItems{?query*}", pathParameters) + { + } + + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public AssignedTodoItemsRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/people/{id}/assignedTodoItems{?query*}", rawUrl) + { + } + + /// + /// Retrieves the related todoItems of an individual person's assignedTodoItems relationship. + /// + /// A + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 404 status code + public async Task GetAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToGetRequestInformation(requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + return await RequestAdapter.SendAsync(requestInfo, global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemCollectionResponseDocument.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + public async Task HeadAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToHeadRequestInformation(requestConfiguration); + await RequestAdapter.SendNoContentAsync(requestInfo, default, cancellationToken).ConfigureAwait(false); + } + + /// + /// Retrieves the related todoItems of an individual person's assignedTodoItems relationship. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToGetRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.GET, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + return requestInfo; + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToHeadRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.HEAD, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + return requestInfo; + } + + /// + /// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored. + /// + /// A + /// The raw URL to use for the request builder. + public global::OpenApiKiotaClientExample.GeneratedCode.Api.People.Item.AssignedTodoItems.AssignedTodoItemsRequestBuilder WithUrl(string rawUrl) + { + return new global::OpenApiKiotaClientExample.GeneratedCode.Api.People.Item.AssignedTodoItems.AssignedTodoItemsRequestBuilder(rawUrl, RequestAdapter); + } + + /// + /// Retrieves the related todoItems of an individual person's assignedTodoItems relationship. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class AssignedTodoItemsRequestBuilderGetQueryParameters + { + /// For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class AssignedTodoItemsRequestBuilderHeadQueryParameters + { + /// For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/People/Item/OwnedTodoItems/OwnedTodoItemsRequestBuilder.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/People/Item/OwnedTodoItems/OwnedTodoItemsRequestBuilder.cs new file mode 100644 index 0000000000..2981e5fa65 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/People/Item/OwnedTodoItems/OwnedTodoItemsRequestBuilder.cs @@ -0,0 +1,128 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions; +using OpenApiKiotaClientExample.GeneratedCode.Models; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Threading; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Api.People.Item.OwnedTodoItems +{ + /// + /// Builds and executes requests for operations under \api\people\{id}\ownedTodoItems + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class OwnedTodoItemsRequestBuilder : BaseRequestBuilder + { + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public OwnedTodoItemsRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/people/{id}/ownedTodoItems{?query*}", pathParameters) + { + } + + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public OwnedTodoItemsRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/people/{id}/ownedTodoItems{?query*}", rawUrl) + { + } + + /// + /// Retrieves the related todoItems of an individual person's ownedTodoItems relationship. + /// + /// A + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 404 status code + public async Task GetAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToGetRequestInformation(requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + return await RequestAdapter.SendAsync(requestInfo, global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemCollectionResponseDocument.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + public async Task HeadAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToHeadRequestInformation(requestConfiguration); + await RequestAdapter.SendNoContentAsync(requestInfo, default, cancellationToken).ConfigureAwait(false); + } + + /// + /// Retrieves the related todoItems of an individual person's ownedTodoItems relationship. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToGetRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.GET, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + return requestInfo; + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToHeadRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.HEAD, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + return requestInfo; + } + + /// + /// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored. + /// + /// A + /// The raw URL to use for the request builder. + public global::OpenApiKiotaClientExample.GeneratedCode.Api.People.Item.OwnedTodoItems.OwnedTodoItemsRequestBuilder WithUrl(string rawUrl) + { + return new global::OpenApiKiotaClientExample.GeneratedCode.Api.People.Item.OwnedTodoItems.OwnedTodoItemsRequestBuilder(rawUrl, RequestAdapter); + } + + /// + /// Retrieves the related todoItems of an individual person's ownedTodoItems relationship. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class OwnedTodoItemsRequestBuilderGetQueryParameters + { + /// For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class OwnedTodoItemsRequestBuilderHeadQueryParameters + { + /// For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/People/Item/PeopleItemRequestBuilder.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/People/Item/PeopleItemRequestBuilder.cs new file mode 100644 index 0000000000..567ed904c8 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/People/Item/PeopleItemRequestBuilder.cs @@ -0,0 +1,230 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions; +using OpenApiKiotaClientExample.GeneratedCode.Api.People.Item.AssignedTodoItems; +using OpenApiKiotaClientExample.GeneratedCode.Api.People.Item.OwnedTodoItems; +using OpenApiKiotaClientExample.GeneratedCode.Api.People.Item.Relationships; +using OpenApiKiotaClientExample.GeneratedCode.Models; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Threading; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Api.People.Item +{ + /// + /// Builds and executes requests for operations under \api\people\{id} + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class PeopleItemRequestBuilder : BaseRequestBuilder + { + /// The assignedTodoItems property + public global::OpenApiKiotaClientExample.GeneratedCode.Api.People.Item.AssignedTodoItems.AssignedTodoItemsRequestBuilder AssignedTodoItems + { + get => new global::OpenApiKiotaClientExample.GeneratedCode.Api.People.Item.AssignedTodoItems.AssignedTodoItemsRequestBuilder(PathParameters, RequestAdapter); + } + + /// The ownedTodoItems property + public global::OpenApiKiotaClientExample.GeneratedCode.Api.People.Item.OwnedTodoItems.OwnedTodoItemsRequestBuilder OwnedTodoItems + { + get => new global::OpenApiKiotaClientExample.GeneratedCode.Api.People.Item.OwnedTodoItems.OwnedTodoItemsRequestBuilder(PathParameters, RequestAdapter); + } + + /// The relationships property + public global::OpenApiKiotaClientExample.GeneratedCode.Api.People.Item.Relationships.RelationshipsRequestBuilder Relationships + { + get => new global::OpenApiKiotaClientExample.GeneratedCode.Api.People.Item.Relationships.RelationshipsRequestBuilder(PathParameters, RequestAdapter); + } + + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public PeopleItemRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/people/{id}{?query*}", pathParameters) + { + } + + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public PeopleItemRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/people/{id}{?query*}", rawUrl) + { + } + + /// + /// Deletes an existing person by its identifier. + /// + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 404 status code + public async Task DeleteAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToDeleteRequestInformation(requestConfiguration); + var errorMapping = new Dictionary> + { + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + await RequestAdapter.SendNoContentAsync(requestInfo, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Retrieves an individual person by its identifier. + /// + /// A + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 404 status code + public async Task GetAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToGetRequestInformation(requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + return await RequestAdapter.SendAsync(requestInfo, global::OpenApiKiotaClientExample.GeneratedCode.Models.PrimaryPersonResponseDocument.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + public async Task HeadAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToHeadRequestInformation(requestConfiguration); + await RequestAdapter.SendNoContentAsync(requestInfo, default, cancellationToken).ConfigureAwait(false); + } + + /// + /// Updates an existing person. + /// + /// A + /// The request body + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 404 status code + /// When receiving a 409 status code + /// When receiving a 422 status code + public async Task PatchAsync(global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdatePersonRequestDocument body, Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = ToPatchRequestInformation(body, requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "409", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "422", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + return await RequestAdapter.SendAsync(requestInfo, global::OpenApiKiotaClientExample.GeneratedCode.Models.PrimaryPersonResponseDocument.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Deletes an existing person by its identifier. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToDeleteRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.DELETE, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + return requestInfo; + } + + /// + /// Retrieves an individual person by its identifier. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToGetRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.GET, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + return requestInfo; + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToHeadRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.HEAD, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + return requestInfo; + } + + /// + /// Updates an existing person. + /// + /// A + /// The request body + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToPatchRequestInformation(global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdatePersonRequestDocument body, Action>? requestConfiguration = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = new RequestInformation(Method.PATCH, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + requestInfo.SetContentFromParsable(RequestAdapter, "application/vnd.api+json;ext=openapi", body); + return requestInfo; + } + + /// + /// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored. + /// + /// A + /// The raw URL to use for the request builder. + public global::OpenApiKiotaClientExample.GeneratedCode.Api.People.Item.PeopleItemRequestBuilder WithUrl(string rawUrl) + { + return new global::OpenApiKiotaClientExample.GeneratedCode.Api.People.Item.PeopleItemRequestBuilder(rawUrl, RequestAdapter); + } + + /// + /// Retrieves an individual person by its identifier. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class PeopleItemRequestBuilderGetQueryParameters + { + /// For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class PeopleItemRequestBuilderHeadQueryParameters + { + /// For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + + /// + /// Updates an existing person. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class PeopleItemRequestBuilderPatchQueryParameters + { + /// For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/People/Item/Relationships/AssignedTodoItems/AssignedTodoItemsRequestBuilder.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/People/Item/Relationships/AssignedTodoItems/AssignedTodoItemsRequestBuilder.cs new file mode 100644 index 0000000000..14c018d51f --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/People/Item/Relationships/AssignedTodoItems/AssignedTodoItemsRequestBuilder.cs @@ -0,0 +1,248 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions; +using OpenApiKiotaClientExample.GeneratedCode.Models; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Threading; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Api.People.Item.Relationships.AssignedTodoItems +{ + /// + /// Builds and executes requests for operations under \api\people\{id}\relationships\assignedTodoItems + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class AssignedTodoItemsRequestBuilder : BaseRequestBuilder + { + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public AssignedTodoItemsRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/people/{id}/relationships/assignedTodoItems{?query*}", pathParameters) + { + } + + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public AssignedTodoItemsRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/people/{id}/relationships/assignedTodoItems{?query*}", rawUrl) + { + } + + /// + /// Removes existing todoItems from the assignedTodoItems relationship of an individual person. + /// + /// The request body + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 404 status code + /// When receiving a 409 status code + /// When receiving a 422 status code + public async Task DeleteAsync(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInRequest body, Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = ToDeleteRequestInformation(body, requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "409", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "422", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + await RequestAdapter.SendNoContentAsync(requestInfo, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Retrieves the related todoItem identities of an individual person's assignedTodoItems relationship. + /// + /// A + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 404 status code + public async Task GetAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToGetRequestInformation(requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + return await RequestAdapter.SendAsync(requestInfo, global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemIdentifierCollectionResponseDocument.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + public async Task HeadAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToHeadRequestInformation(requestConfiguration); + await RequestAdapter.SendNoContentAsync(requestInfo, default, cancellationToken).ConfigureAwait(false); + } + + /// + /// Assigns existing todoItems to the assignedTodoItems relationship of an individual person. + /// + /// The request body + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 404 status code + /// When receiving a 409 status code + /// When receiving a 422 status code + public async Task PatchAsync(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInRequest body, Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = ToPatchRequestInformation(body, requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "409", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "422", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + await RequestAdapter.SendNoContentAsync(requestInfo, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Adds existing todoItems to the assignedTodoItems relationship of an individual person. + /// + /// The request body + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 404 status code + /// When receiving a 409 status code + /// When receiving a 422 status code + public async Task PostAsync(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInRequest body, Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = ToPostRequestInformation(body, requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "409", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "422", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + await RequestAdapter.SendNoContentAsync(requestInfo, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Removes existing todoItems from the assignedTodoItems relationship of an individual person. + /// + /// A + /// The request body + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToDeleteRequestInformation(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInRequest body, Action>? requestConfiguration = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = new RequestInformation(Method.DELETE, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + requestInfo.SetContentFromParsable(RequestAdapter, "application/vnd.api+json;ext=openapi", body); + return requestInfo; + } + + /// + /// Retrieves the related todoItem identities of an individual person's assignedTodoItems relationship. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToGetRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.GET, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + return requestInfo; + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToHeadRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.HEAD, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + return requestInfo; + } + + /// + /// Assigns existing todoItems to the assignedTodoItems relationship of an individual person. + /// + /// A + /// The request body + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToPatchRequestInformation(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInRequest body, Action>? requestConfiguration = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = new RequestInformation(Method.PATCH, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + requestInfo.SetContentFromParsable(RequestAdapter, "application/vnd.api+json;ext=openapi", body); + return requestInfo; + } + + /// + /// Adds existing todoItems to the assignedTodoItems relationship of an individual person. + /// + /// A + /// The request body + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToPostRequestInformation(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInRequest body, Action>? requestConfiguration = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = new RequestInformation(Method.POST, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + requestInfo.SetContentFromParsable(RequestAdapter, "application/vnd.api+json;ext=openapi", body); + return requestInfo; + } + + /// + /// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored. + /// + /// A + /// The raw URL to use for the request builder. + public global::OpenApiKiotaClientExample.GeneratedCode.Api.People.Item.Relationships.AssignedTodoItems.AssignedTodoItemsRequestBuilder WithUrl(string rawUrl) + { + return new global::OpenApiKiotaClientExample.GeneratedCode.Api.People.Item.Relationships.AssignedTodoItems.AssignedTodoItemsRequestBuilder(rawUrl, RequestAdapter); + } + + /// + /// Retrieves the related todoItem identities of an individual person's assignedTodoItems relationship. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class AssignedTodoItemsRequestBuilderGetQueryParameters + { + /// For syntax, see the documentation for the [`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class AssignedTodoItemsRequestBuilderHeadQueryParameters + { + /// For syntax, see the documentation for the [`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/People/Item/Relationships/OwnedTodoItems/OwnedTodoItemsRequestBuilder.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/People/Item/Relationships/OwnedTodoItems/OwnedTodoItemsRequestBuilder.cs new file mode 100644 index 0000000000..9f27d97c3b --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/People/Item/Relationships/OwnedTodoItems/OwnedTodoItemsRequestBuilder.cs @@ -0,0 +1,248 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions; +using OpenApiKiotaClientExample.GeneratedCode.Models; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Threading; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Api.People.Item.Relationships.OwnedTodoItems +{ + /// + /// Builds and executes requests for operations under \api\people\{id}\relationships\ownedTodoItems + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class OwnedTodoItemsRequestBuilder : BaseRequestBuilder + { + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public OwnedTodoItemsRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/people/{id}/relationships/ownedTodoItems{?query*}", pathParameters) + { + } + + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public OwnedTodoItemsRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/people/{id}/relationships/ownedTodoItems{?query*}", rawUrl) + { + } + + /// + /// Removes existing todoItems from the ownedTodoItems relationship of an individual person. + /// + /// The request body + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 404 status code + /// When receiving a 409 status code + /// When receiving a 422 status code + public async Task DeleteAsync(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInRequest body, Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = ToDeleteRequestInformation(body, requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "409", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "422", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + await RequestAdapter.SendNoContentAsync(requestInfo, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Retrieves the related todoItem identities of an individual person's ownedTodoItems relationship. + /// + /// A + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 404 status code + public async Task GetAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToGetRequestInformation(requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + return await RequestAdapter.SendAsync(requestInfo, global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemIdentifierCollectionResponseDocument.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + public async Task HeadAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToHeadRequestInformation(requestConfiguration); + await RequestAdapter.SendNoContentAsync(requestInfo, default, cancellationToken).ConfigureAwait(false); + } + + /// + /// Assigns existing todoItems to the ownedTodoItems relationship of an individual person. + /// + /// The request body + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 404 status code + /// When receiving a 409 status code + /// When receiving a 422 status code + public async Task PatchAsync(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInRequest body, Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = ToPatchRequestInformation(body, requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "409", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "422", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + await RequestAdapter.SendNoContentAsync(requestInfo, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Adds existing todoItems to the ownedTodoItems relationship of an individual person. + /// + /// The request body + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 404 status code + /// When receiving a 409 status code + /// When receiving a 422 status code + public async Task PostAsync(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInRequest body, Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = ToPostRequestInformation(body, requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "409", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "422", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + await RequestAdapter.SendNoContentAsync(requestInfo, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Removes existing todoItems from the ownedTodoItems relationship of an individual person. + /// + /// A + /// The request body + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToDeleteRequestInformation(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInRequest body, Action>? requestConfiguration = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = new RequestInformation(Method.DELETE, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + requestInfo.SetContentFromParsable(RequestAdapter, "application/vnd.api+json;ext=openapi", body); + return requestInfo; + } + + /// + /// Retrieves the related todoItem identities of an individual person's ownedTodoItems relationship. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToGetRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.GET, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + return requestInfo; + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToHeadRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.HEAD, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + return requestInfo; + } + + /// + /// Assigns existing todoItems to the ownedTodoItems relationship of an individual person. + /// + /// A + /// The request body + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToPatchRequestInformation(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInRequest body, Action>? requestConfiguration = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = new RequestInformation(Method.PATCH, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + requestInfo.SetContentFromParsable(RequestAdapter, "application/vnd.api+json;ext=openapi", body); + return requestInfo; + } + + /// + /// Adds existing todoItems to the ownedTodoItems relationship of an individual person. + /// + /// A + /// The request body + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToPostRequestInformation(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInRequest body, Action>? requestConfiguration = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = new RequestInformation(Method.POST, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + requestInfo.SetContentFromParsable(RequestAdapter, "application/vnd.api+json;ext=openapi", body); + return requestInfo; + } + + /// + /// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored. + /// + /// A + /// The raw URL to use for the request builder. + public global::OpenApiKiotaClientExample.GeneratedCode.Api.People.Item.Relationships.OwnedTodoItems.OwnedTodoItemsRequestBuilder WithUrl(string rawUrl) + { + return new global::OpenApiKiotaClientExample.GeneratedCode.Api.People.Item.Relationships.OwnedTodoItems.OwnedTodoItemsRequestBuilder(rawUrl, RequestAdapter); + } + + /// + /// Retrieves the related todoItem identities of an individual person's ownedTodoItems relationship. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class OwnedTodoItemsRequestBuilderGetQueryParameters + { + /// For syntax, see the documentation for the [`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class OwnedTodoItemsRequestBuilderHeadQueryParameters + { + /// For syntax, see the documentation for the [`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/People/Item/Relationships/RelationshipsRequestBuilder.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/People/Item/Relationships/RelationshipsRequestBuilder.cs new file mode 100644 index 0000000000..2c9e9beafe --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/People/Item/Relationships/RelationshipsRequestBuilder.cs @@ -0,0 +1,52 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions; +using OpenApiKiotaClientExample.GeneratedCode.Api.People.Item.Relationships.AssignedTodoItems; +using OpenApiKiotaClientExample.GeneratedCode.Api.People.Item.Relationships.OwnedTodoItems; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Api.People.Item.Relationships +{ + /// + /// Builds and executes requests for operations under \api\people\{id}\relationships + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class RelationshipsRequestBuilder : BaseRequestBuilder + { + /// The assignedTodoItems property + public global::OpenApiKiotaClientExample.GeneratedCode.Api.People.Item.Relationships.AssignedTodoItems.AssignedTodoItemsRequestBuilder AssignedTodoItems + { + get => new global::OpenApiKiotaClientExample.GeneratedCode.Api.People.Item.Relationships.AssignedTodoItems.AssignedTodoItemsRequestBuilder(PathParameters, RequestAdapter); + } + + /// The ownedTodoItems property + public global::OpenApiKiotaClientExample.GeneratedCode.Api.People.Item.Relationships.OwnedTodoItems.OwnedTodoItemsRequestBuilder OwnedTodoItems + { + get => new global::OpenApiKiotaClientExample.GeneratedCode.Api.People.Item.Relationships.OwnedTodoItems.OwnedTodoItemsRequestBuilder(PathParameters, RequestAdapter); + } + + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public RelationshipsRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/people/{id}/relationships", pathParameters) + { + } + + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public RelationshipsRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/people/{id}/relationships", rawUrl) + { + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/People/PeopleRequestBuilder.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/People/PeopleRequestBuilder.cs new file mode 100644 index 0000000000..3a36d323c4 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/People/PeopleRequestBuilder.cs @@ -0,0 +1,194 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions; +using OpenApiKiotaClientExample.GeneratedCode.Api.People.Item; +using OpenApiKiotaClientExample.GeneratedCode.Models; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Threading; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Api.People +{ + /// + /// Builds and executes requests for operations under \api\people + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class PeopleRequestBuilder : BaseRequestBuilder + { + /// Gets an item from the OpenApiKiotaClientExample.GeneratedCode.api.people.item collection + /// The identifier of the person to retrieve. + /// A + public global::OpenApiKiotaClientExample.GeneratedCode.Api.People.Item.PeopleItemRequestBuilder this[string position] + { + get + { + var urlTplParams = new Dictionary(PathParameters); + urlTplParams.Add("id", position); + return new global::OpenApiKiotaClientExample.GeneratedCode.Api.People.Item.PeopleItemRequestBuilder(urlTplParams, RequestAdapter); + } + } + + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public PeopleRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/people{?query*}", pathParameters) + { + } + + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public PeopleRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/people{?query*}", rawUrl) + { + } + + /// + /// Retrieves a collection of people. + /// + /// A + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + public async Task GetAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToGetRequestInformation(requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + return await RequestAdapter.SendAsync(requestInfo, global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonCollectionResponseDocument.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + public async Task HeadAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToHeadRequestInformation(requestConfiguration); + await RequestAdapter.SendNoContentAsync(requestInfo, default, cancellationToken).ConfigureAwait(false); + } + + /// + /// Creates a new person. + /// + /// A + /// The request body + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 403 status code + /// When receiving a 404 status code + /// When receiving a 409 status code + /// When receiving a 422 status code + public async Task PostAsync(global::OpenApiKiotaClientExample.GeneratedCode.Models.CreatePersonRequestDocument body, Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = ToPostRequestInformation(body, requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "403", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "409", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "422", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + return await RequestAdapter.SendAsync(requestInfo, global::OpenApiKiotaClientExample.GeneratedCode.Models.PrimaryPersonResponseDocument.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Retrieves a collection of people. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToGetRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.GET, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + return requestInfo; + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToHeadRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.HEAD, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + return requestInfo; + } + + /// + /// Creates a new person. + /// + /// A + /// The request body + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToPostRequestInformation(global::OpenApiKiotaClientExample.GeneratedCode.Models.CreatePersonRequestDocument body, Action>? requestConfiguration = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = new RequestInformation(Method.POST, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + requestInfo.SetContentFromParsable(RequestAdapter, "application/vnd.api+json;ext=openapi", body); + return requestInfo; + } + + /// + /// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored. + /// + /// A + /// The raw URL to use for the request builder. + public global::OpenApiKiotaClientExample.GeneratedCode.Api.People.PeopleRequestBuilder WithUrl(string rawUrl) + { + return new global::OpenApiKiotaClientExample.GeneratedCode.Api.People.PeopleRequestBuilder(rawUrl, RequestAdapter); + } + + /// + /// Retrieves a collection of people. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class PeopleRequestBuilderGetQueryParameters + { + /// For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class PeopleRequestBuilderHeadQueryParameters + { + /// For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + + /// + /// Creates a new person. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class PeopleRequestBuilderPostQueryParameters + { + /// For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/Tags/Item/Relationships/RelationshipsRequestBuilder.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/Tags/Item/Relationships/RelationshipsRequestBuilder.cs new file mode 100644 index 0000000000..ecf2f5cc86 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/Tags/Item/Relationships/RelationshipsRequestBuilder.cs @@ -0,0 +1,45 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions; +using OpenApiKiotaClientExample.GeneratedCode.Api.Tags.Item.Relationships.TodoItems; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Api.Tags.Item.Relationships +{ + /// + /// Builds and executes requests for operations under \api\tags\{id}\relationships + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class RelationshipsRequestBuilder : BaseRequestBuilder + { + /// The todoItems property + public global::OpenApiKiotaClientExample.GeneratedCode.Api.Tags.Item.Relationships.TodoItems.TodoItemsRequestBuilder TodoItems + { + get => new global::OpenApiKiotaClientExample.GeneratedCode.Api.Tags.Item.Relationships.TodoItems.TodoItemsRequestBuilder(PathParameters, RequestAdapter); + } + + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public RelationshipsRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/tags/{id}/relationships", pathParameters) + { + } + + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public RelationshipsRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/tags/{id}/relationships", rawUrl) + { + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/Tags/Item/Relationships/TodoItems/TodoItemsRequestBuilder.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/Tags/Item/Relationships/TodoItems/TodoItemsRequestBuilder.cs new file mode 100644 index 0000000000..6020933f3d --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/Tags/Item/Relationships/TodoItems/TodoItemsRequestBuilder.cs @@ -0,0 +1,248 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions; +using OpenApiKiotaClientExample.GeneratedCode.Models; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Threading; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Api.Tags.Item.Relationships.TodoItems +{ + /// + /// Builds and executes requests for operations under \api\tags\{id}\relationships\todoItems + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class TodoItemsRequestBuilder : BaseRequestBuilder + { + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public TodoItemsRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/tags/{id}/relationships/todoItems{?query*}", pathParameters) + { + } + + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public TodoItemsRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/tags/{id}/relationships/todoItems{?query*}", rawUrl) + { + } + + /// + /// Removes existing todoItems from the todoItems relationship of an individual tag. + /// + /// The request body + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 404 status code + /// When receiving a 409 status code + /// When receiving a 422 status code + public async Task DeleteAsync(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInRequest body, Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = ToDeleteRequestInformation(body, requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "409", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "422", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + await RequestAdapter.SendNoContentAsync(requestInfo, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Retrieves the related todoItem identities of an individual tag's todoItems relationship. + /// + /// A + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 404 status code + public async Task GetAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToGetRequestInformation(requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + return await RequestAdapter.SendAsync(requestInfo, global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemIdentifierCollectionResponseDocument.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + public async Task HeadAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToHeadRequestInformation(requestConfiguration); + await RequestAdapter.SendNoContentAsync(requestInfo, default, cancellationToken).ConfigureAwait(false); + } + + /// + /// Assigns existing todoItems to the todoItems relationship of an individual tag. + /// + /// The request body + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 404 status code + /// When receiving a 409 status code + /// When receiving a 422 status code + public async Task PatchAsync(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInRequest body, Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = ToPatchRequestInformation(body, requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "409", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "422", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + await RequestAdapter.SendNoContentAsync(requestInfo, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Adds existing todoItems to the todoItems relationship of an individual tag. + /// + /// The request body + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 404 status code + /// When receiving a 409 status code + /// When receiving a 422 status code + public async Task PostAsync(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInRequest body, Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = ToPostRequestInformation(body, requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "409", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "422", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + await RequestAdapter.SendNoContentAsync(requestInfo, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Removes existing todoItems from the todoItems relationship of an individual tag. + /// + /// A + /// The request body + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToDeleteRequestInformation(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInRequest body, Action>? requestConfiguration = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = new RequestInformation(Method.DELETE, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + requestInfo.SetContentFromParsable(RequestAdapter, "application/vnd.api+json;ext=openapi", body); + return requestInfo; + } + + /// + /// Retrieves the related todoItem identities of an individual tag's todoItems relationship. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToGetRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.GET, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + return requestInfo; + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToHeadRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.HEAD, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + return requestInfo; + } + + /// + /// Assigns existing todoItems to the todoItems relationship of an individual tag. + /// + /// A + /// The request body + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToPatchRequestInformation(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInRequest body, Action>? requestConfiguration = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = new RequestInformation(Method.PATCH, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + requestInfo.SetContentFromParsable(RequestAdapter, "application/vnd.api+json;ext=openapi", body); + return requestInfo; + } + + /// + /// Adds existing todoItems to the todoItems relationship of an individual tag. + /// + /// A + /// The request body + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToPostRequestInformation(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInRequest body, Action>? requestConfiguration = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = new RequestInformation(Method.POST, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + requestInfo.SetContentFromParsable(RequestAdapter, "application/vnd.api+json;ext=openapi", body); + return requestInfo; + } + + /// + /// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored. + /// + /// A + /// The raw URL to use for the request builder. + public global::OpenApiKiotaClientExample.GeneratedCode.Api.Tags.Item.Relationships.TodoItems.TodoItemsRequestBuilder WithUrl(string rawUrl) + { + return new global::OpenApiKiotaClientExample.GeneratedCode.Api.Tags.Item.Relationships.TodoItems.TodoItemsRequestBuilder(rawUrl, RequestAdapter); + } + + /// + /// Retrieves the related todoItem identities of an individual tag's todoItems relationship. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class TodoItemsRequestBuilderGetQueryParameters + { + /// For syntax, see the documentation for the [`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class TodoItemsRequestBuilderHeadQueryParameters + { + /// For syntax, see the documentation for the [`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/Tags/Item/TagsItemRequestBuilder.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/Tags/Item/TagsItemRequestBuilder.cs new file mode 100644 index 0000000000..5321238e2b --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/Tags/Item/TagsItemRequestBuilder.cs @@ -0,0 +1,223 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions; +using OpenApiKiotaClientExample.GeneratedCode.Api.Tags.Item.Relationships; +using OpenApiKiotaClientExample.GeneratedCode.Api.Tags.Item.TodoItems; +using OpenApiKiotaClientExample.GeneratedCode.Models; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Threading; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Api.Tags.Item +{ + /// + /// Builds and executes requests for operations under \api\tags\{id} + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class TagsItemRequestBuilder : BaseRequestBuilder + { + /// The relationships property + public global::OpenApiKiotaClientExample.GeneratedCode.Api.Tags.Item.Relationships.RelationshipsRequestBuilder Relationships + { + get => new global::OpenApiKiotaClientExample.GeneratedCode.Api.Tags.Item.Relationships.RelationshipsRequestBuilder(PathParameters, RequestAdapter); + } + + /// The todoItems property + public global::OpenApiKiotaClientExample.GeneratedCode.Api.Tags.Item.TodoItems.TodoItemsRequestBuilder TodoItems + { + get => new global::OpenApiKiotaClientExample.GeneratedCode.Api.Tags.Item.TodoItems.TodoItemsRequestBuilder(PathParameters, RequestAdapter); + } + + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public TagsItemRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/tags/{id}{?query*}", pathParameters) + { + } + + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public TagsItemRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/tags/{id}{?query*}", rawUrl) + { + } + + /// + /// Deletes an existing tag by its identifier. + /// + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 404 status code + public async Task DeleteAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToDeleteRequestInformation(requestConfiguration); + var errorMapping = new Dictionary> + { + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + await RequestAdapter.SendNoContentAsync(requestInfo, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Retrieves an individual tag by its identifier. + /// + /// A + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 404 status code + public async Task GetAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToGetRequestInformation(requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + return await RequestAdapter.SendAsync(requestInfo, global::OpenApiKiotaClientExample.GeneratedCode.Models.PrimaryTagResponseDocument.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + public async Task HeadAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToHeadRequestInformation(requestConfiguration); + await RequestAdapter.SendNoContentAsync(requestInfo, default, cancellationToken).ConfigureAwait(false); + } + + /// + /// Updates an existing tag. + /// + /// A + /// The request body + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 404 status code + /// When receiving a 409 status code + /// When receiving a 422 status code + public async Task PatchAsync(global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateTagRequestDocument body, Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = ToPatchRequestInformation(body, requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "409", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "422", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + return await RequestAdapter.SendAsync(requestInfo, global::OpenApiKiotaClientExample.GeneratedCode.Models.PrimaryTagResponseDocument.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Deletes an existing tag by its identifier. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToDeleteRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.DELETE, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + return requestInfo; + } + + /// + /// Retrieves an individual tag by its identifier. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToGetRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.GET, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + return requestInfo; + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToHeadRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.HEAD, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + return requestInfo; + } + + /// + /// Updates an existing tag. + /// + /// A + /// The request body + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToPatchRequestInformation(global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateTagRequestDocument body, Action>? requestConfiguration = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = new RequestInformation(Method.PATCH, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + requestInfo.SetContentFromParsable(RequestAdapter, "application/vnd.api+json;ext=openapi", body); + return requestInfo; + } + + /// + /// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored. + /// + /// A + /// The raw URL to use for the request builder. + public global::OpenApiKiotaClientExample.GeneratedCode.Api.Tags.Item.TagsItemRequestBuilder WithUrl(string rawUrl) + { + return new global::OpenApiKiotaClientExample.GeneratedCode.Api.Tags.Item.TagsItemRequestBuilder(rawUrl, RequestAdapter); + } + + /// + /// Retrieves an individual tag by its identifier. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class TagsItemRequestBuilderGetQueryParameters + { + /// For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class TagsItemRequestBuilderHeadQueryParameters + { + /// For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + + /// + /// Updates an existing tag. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class TagsItemRequestBuilderPatchQueryParameters + { + /// For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/Tags/Item/TodoItems/TodoItemsRequestBuilder.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/Tags/Item/TodoItems/TodoItemsRequestBuilder.cs new file mode 100644 index 0000000000..c5c673d844 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/Tags/Item/TodoItems/TodoItemsRequestBuilder.cs @@ -0,0 +1,128 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions; +using OpenApiKiotaClientExample.GeneratedCode.Models; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Threading; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Api.Tags.Item.TodoItems +{ + /// + /// Builds and executes requests for operations under \api\tags\{id}\todoItems + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class TodoItemsRequestBuilder : BaseRequestBuilder + { + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public TodoItemsRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/tags/{id}/todoItems{?query*}", pathParameters) + { + } + + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public TodoItemsRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/tags/{id}/todoItems{?query*}", rawUrl) + { + } + + /// + /// Retrieves the related todoItems of an individual tag's todoItems relationship. + /// + /// A + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 404 status code + public async Task GetAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToGetRequestInformation(requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + return await RequestAdapter.SendAsync(requestInfo, global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemCollectionResponseDocument.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + public async Task HeadAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToHeadRequestInformation(requestConfiguration); + await RequestAdapter.SendNoContentAsync(requestInfo, default, cancellationToken).ConfigureAwait(false); + } + + /// + /// Retrieves the related todoItems of an individual tag's todoItems relationship. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToGetRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.GET, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + return requestInfo; + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToHeadRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.HEAD, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + return requestInfo; + } + + /// + /// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored. + /// + /// A + /// The raw URL to use for the request builder. + public global::OpenApiKiotaClientExample.GeneratedCode.Api.Tags.Item.TodoItems.TodoItemsRequestBuilder WithUrl(string rawUrl) + { + return new global::OpenApiKiotaClientExample.GeneratedCode.Api.Tags.Item.TodoItems.TodoItemsRequestBuilder(rawUrl, RequestAdapter); + } + + /// + /// Retrieves the related todoItems of an individual tag's todoItems relationship. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class TodoItemsRequestBuilderGetQueryParameters + { + /// For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class TodoItemsRequestBuilderHeadQueryParameters + { + /// For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/Tags/TagsRequestBuilder.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/Tags/TagsRequestBuilder.cs new file mode 100644 index 0000000000..ccc88d242c --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/Tags/TagsRequestBuilder.cs @@ -0,0 +1,194 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions; +using OpenApiKiotaClientExample.GeneratedCode.Api.Tags.Item; +using OpenApiKiotaClientExample.GeneratedCode.Models; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Threading; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Api.Tags +{ + /// + /// Builds and executes requests for operations under \api\tags + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class TagsRequestBuilder : BaseRequestBuilder + { + /// Gets an item from the OpenApiKiotaClientExample.GeneratedCode.api.tags.item collection + /// The identifier of the tag to retrieve. + /// A + public global::OpenApiKiotaClientExample.GeneratedCode.Api.Tags.Item.TagsItemRequestBuilder this[string position] + { + get + { + var urlTplParams = new Dictionary(PathParameters); + urlTplParams.Add("id", position); + return new global::OpenApiKiotaClientExample.GeneratedCode.Api.Tags.Item.TagsItemRequestBuilder(urlTplParams, RequestAdapter); + } + } + + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public TagsRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/tags{?query*}", pathParameters) + { + } + + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public TagsRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/tags{?query*}", rawUrl) + { + } + + /// + /// Retrieves a collection of tags. + /// + /// A + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + public async Task GetAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToGetRequestInformation(requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + return await RequestAdapter.SendAsync(requestInfo, global::OpenApiKiotaClientExample.GeneratedCode.Models.TagCollectionResponseDocument.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + public async Task HeadAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToHeadRequestInformation(requestConfiguration); + await RequestAdapter.SendNoContentAsync(requestInfo, default, cancellationToken).ConfigureAwait(false); + } + + /// + /// Creates a new tag. + /// + /// A + /// The request body + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 403 status code + /// When receiving a 404 status code + /// When receiving a 409 status code + /// When receiving a 422 status code + public async Task PostAsync(global::OpenApiKiotaClientExample.GeneratedCode.Models.CreateTagRequestDocument body, Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = ToPostRequestInformation(body, requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "403", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "409", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "422", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + return await RequestAdapter.SendAsync(requestInfo, global::OpenApiKiotaClientExample.GeneratedCode.Models.PrimaryTagResponseDocument.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Retrieves a collection of tags. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToGetRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.GET, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + return requestInfo; + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToHeadRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.HEAD, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + return requestInfo; + } + + /// + /// Creates a new tag. + /// + /// A + /// The request body + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToPostRequestInformation(global::OpenApiKiotaClientExample.GeneratedCode.Models.CreateTagRequestDocument body, Action>? requestConfiguration = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = new RequestInformation(Method.POST, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + requestInfo.SetContentFromParsable(RequestAdapter, "application/vnd.api+json;ext=openapi", body); + return requestInfo; + } + + /// + /// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored. + /// + /// A + /// The raw URL to use for the request builder. + public global::OpenApiKiotaClientExample.GeneratedCode.Api.Tags.TagsRequestBuilder WithUrl(string rawUrl) + { + return new global::OpenApiKiotaClientExample.GeneratedCode.Api.Tags.TagsRequestBuilder(rawUrl, RequestAdapter); + } + + /// + /// Retrieves a collection of tags. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class TagsRequestBuilderGetQueryParameters + { + /// For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class TagsRequestBuilderHeadQueryParameters + { + /// For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + + /// + /// Creates a new tag. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class TagsRequestBuilderPostQueryParameters + { + /// For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/TodoItems/Item/Assignee/AssigneeRequestBuilder.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/TodoItems/Item/Assignee/AssigneeRequestBuilder.cs new file mode 100644 index 0000000000..10601bcef3 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/TodoItems/Item/Assignee/AssigneeRequestBuilder.cs @@ -0,0 +1,128 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions; +using OpenApiKiotaClientExample.GeneratedCode.Models; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Threading; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Assignee +{ + /// + /// Builds and executes requests for operations under \api\todoItems\{id}\assignee + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class AssigneeRequestBuilder : BaseRequestBuilder + { + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public AssigneeRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/todoItems/{id}/assignee{?query*}", pathParameters) + { + } + + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public AssigneeRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/todoItems/{id}/assignee{?query*}", rawUrl) + { + } + + /// + /// Retrieves the related person of an individual todoItem's assignee relationship. + /// + /// A + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 404 status code + public async Task GetAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToGetRequestInformation(requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + return await RequestAdapter.SendAsync(requestInfo, global::OpenApiKiotaClientExample.GeneratedCode.Models.NullableSecondaryPersonResponseDocument.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + public async Task HeadAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToHeadRequestInformation(requestConfiguration); + await RequestAdapter.SendNoContentAsync(requestInfo, default, cancellationToken).ConfigureAwait(false); + } + + /// + /// Retrieves the related person of an individual todoItem's assignee relationship. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToGetRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.GET, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + return requestInfo; + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToHeadRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.HEAD, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + return requestInfo; + } + + /// + /// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored. + /// + /// A + /// The raw URL to use for the request builder. + public global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Assignee.AssigneeRequestBuilder WithUrl(string rawUrl) + { + return new global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Assignee.AssigneeRequestBuilder(rawUrl, RequestAdapter); + } + + /// + /// Retrieves the related person of an individual todoItem's assignee relationship. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class AssigneeRequestBuilderGetQueryParameters + { + /// For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class AssigneeRequestBuilderHeadQueryParameters + { + /// For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/TodoItems/Item/Owner/OwnerRequestBuilder.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/TodoItems/Item/Owner/OwnerRequestBuilder.cs new file mode 100644 index 0000000000..8fe72c9e38 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/TodoItems/Item/Owner/OwnerRequestBuilder.cs @@ -0,0 +1,128 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions; +using OpenApiKiotaClientExample.GeneratedCode.Models; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Threading; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Owner +{ + /// + /// Builds and executes requests for operations under \api\todoItems\{id}\owner + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class OwnerRequestBuilder : BaseRequestBuilder + { + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public OwnerRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/todoItems/{id}/owner{?query*}", pathParameters) + { + } + + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public OwnerRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/todoItems/{id}/owner{?query*}", rawUrl) + { + } + + /// + /// Retrieves the related person of an individual todoItem's owner relationship. + /// + /// A + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 404 status code + public async Task GetAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToGetRequestInformation(requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + return await RequestAdapter.SendAsync(requestInfo, global::OpenApiKiotaClientExample.GeneratedCode.Models.SecondaryPersonResponseDocument.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + public async Task HeadAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToHeadRequestInformation(requestConfiguration); + await RequestAdapter.SendNoContentAsync(requestInfo, default, cancellationToken).ConfigureAwait(false); + } + + /// + /// Retrieves the related person of an individual todoItem's owner relationship. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToGetRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.GET, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + return requestInfo; + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToHeadRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.HEAD, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + return requestInfo; + } + + /// + /// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored. + /// + /// A + /// The raw URL to use for the request builder. + public global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Owner.OwnerRequestBuilder WithUrl(string rawUrl) + { + return new global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Owner.OwnerRequestBuilder(rawUrl, RequestAdapter); + } + + /// + /// Retrieves the related person of an individual todoItem's owner relationship. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class OwnerRequestBuilderGetQueryParameters + { + /// For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class OwnerRequestBuilderHeadQueryParameters + { + /// For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/TodoItems/Item/Relationships/Assignee/AssigneeRequestBuilder.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/TodoItems/Item/Relationships/Assignee/AssigneeRequestBuilder.cs new file mode 100644 index 0000000000..5d775fbf73 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/TodoItems/Item/Relationships/Assignee/AssigneeRequestBuilder.cs @@ -0,0 +1,168 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions; +using OpenApiKiotaClientExample.GeneratedCode.Models; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Threading; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Relationships.Assignee +{ + /// + /// Builds and executes requests for operations under \api\todoItems\{id}\relationships\assignee + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class AssigneeRequestBuilder : BaseRequestBuilder + { + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public AssigneeRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/todoItems/{id}/relationships/assignee{?query*}", pathParameters) + { + } + + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public AssigneeRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/todoItems/{id}/relationships/assignee{?query*}", rawUrl) + { + } + + /// + /// Retrieves the related person identity of an individual todoItem's assignee relationship. + /// + /// A + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 404 status code + public async Task GetAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToGetRequestInformation(requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + return await RequestAdapter.SendAsync(requestInfo, global::OpenApiKiotaClientExample.GeneratedCode.Models.NullablePersonIdentifierResponseDocument.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + public async Task HeadAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToHeadRequestInformation(requestConfiguration); + await RequestAdapter.SendNoContentAsync(requestInfo, default, cancellationToken).ConfigureAwait(false); + } + + /// + /// Clears or assigns an existing person to the assignee relationship of an individual todoItem. + /// + /// The request body + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 404 status code + /// When receiving a 409 status code + /// When receiving a 422 status code + public async Task PatchAsync(global::OpenApiKiotaClientExample.GeneratedCode.Models.NullableToOnePersonInRequest body, Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = ToPatchRequestInformation(body, requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "409", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "422", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + await RequestAdapter.SendNoContentAsync(requestInfo, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Retrieves the related person identity of an individual todoItem's assignee relationship. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToGetRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.GET, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + return requestInfo; + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToHeadRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.HEAD, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + return requestInfo; + } + + /// + /// Clears or assigns an existing person to the assignee relationship of an individual todoItem. + /// + /// A + /// The request body + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToPatchRequestInformation(global::OpenApiKiotaClientExample.GeneratedCode.Models.NullableToOnePersonInRequest body, Action>? requestConfiguration = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = new RequestInformation(Method.PATCH, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + requestInfo.SetContentFromParsable(RequestAdapter, "application/vnd.api+json;ext=openapi", body); + return requestInfo; + } + + /// + /// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored. + /// + /// A + /// The raw URL to use for the request builder. + public global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Relationships.Assignee.AssigneeRequestBuilder WithUrl(string rawUrl) + { + return new global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Relationships.Assignee.AssigneeRequestBuilder(rawUrl, RequestAdapter); + } + + /// + /// Retrieves the related person identity of an individual todoItem's assignee relationship. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class AssigneeRequestBuilderGetQueryParameters + { + /// For syntax, see the documentation for the [`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class AssigneeRequestBuilderHeadQueryParameters + { + /// For syntax, see the documentation for the [`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/TodoItems/Item/Relationships/Owner/OwnerRequestBuilder.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/TodoItems/Item/Relationships/Owner/OwnerRequestBuilder.cs new file mode 100644 index 0000000000..abe58c712f --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/TodoItems/Item/Relationships/Owner/OwnerRequestBuilder.cs @@ -0,0 +1,168 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions; +using OpenApiKiotaClientExample.GeneratedCode.Models; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Threading; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Relationships.Owner +{ + /// + /// Builds and executes requests for operations under \api\todoItems\{id}\relationships\owner + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class OwnerRequestBuilder : BaseRequestBuilder + { + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public OwnerRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/todoItems/{id}/relationships/owner{?query*}", pathParameters) + { + } + + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public OwnerRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/todoItems/{id}/relationships/owner{?query*}", rawUrl) + { + } + + /// + /// Retrieves the related person identity of an individual todoItem's owner relationship. + /// + /// A + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 404 status code + public async Task GetAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToGetRequestInformation(requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + return await RequestAdapter.SendAsync(requestInfo, global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonIdentifierResponseDocument.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + public async Task HeadAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToHeadRequestInformation(requestConfiguration); + await RequestAdapter.SendNoContentAsync(requestInfo, default, cancellationToken).ConfigureAwait(false); + } + + /// + /// Assigns an existing person to the owner relationship of an individual todoItem. + /// + /// The request body + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 404 status code + /// When receiving a 409 status code + /// When receiving a 422 status code + public async Task PatchAsync(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToOnePersonInRequest body, Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = ToPatchRequestInformation(body, requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "409", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "422", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + await RequestAdapter.SendNoContentAsync(requestInfo, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Retrieves the related person identity of an individual todoItem's owner relationship. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToGetRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.GET, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + return requestInfo; + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToHeadRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.HEAD, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + return requestInfo; + } + + /// + /// Assigns an existing person to the owner relationship of an individual todoItem. + /// + /// A + /// The request body + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToPatchRequestInformation(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToOnePersonInRequest body, Action>? requestConfiguration = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = new RequestInformation(Method.PATCH, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + requestInfo.SetContentFromParsable(RequestAdapter, "application/vnd.api+json;ext=openapi", body); + return requestInfo; + } + + /// + /// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored. + /// + /// A + /// The raw URL to use for the request builder. + public global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Relationships.Owner.OwnerRequestBuilder WithUrl(string rawUrl) + { + return new global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Relationships.Owner.OwnerRequestBuilder(rawUrl, RequestAdapter); + } + + /// + /// Retrieves the related person identity of an individual todoItem's owner relationship. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class OwnerRequestBuilderGetQueryParameters + { + /// For syntax, see the documentation for the [`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class OwnerRequestBuilderHeadQueryParameters + { + /// For syntax, see the documentation for the [`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/TodoItems/Item/Relationships/RelationshipsRequestBuilder.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/TodoItems/Item/Relationships/RelationshipsRequestBuilder.cs new file mode 100644 index 0000000000..0737f8ce56 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/TodoItems/Item/Relationships/RelationshipsRequestBuilder.cs @@ -0,0 +1,59 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions; +using OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Relationships.Assignee; +using OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Relationships.Owner; +using OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Relationships.Tags; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Relationships +{ + /// + /// Builds and executes requests for operations under \api\todoItems\{id}\relationships + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class RelationshipsRequestBuilder : BaseRequestBuilder + { + /// The assignee property + public global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Relationships.Assignee.AssigneeRequestBuilder Assignee + { + get => new global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Relationships.Assignee.AssigneeRequestBuilder(PathParameters, RequestAdapter); + } + + /// The owner property + public global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Relationships.Owner.OwnerRequestBuilder Owner + { + get => new global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Relationships.Owner.OwnerRequestBuilder(PathParameters, RequestAdapter); + } + + /// The tags property + public global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Relationships.Tags.TagsRequestBuilder Tags + { + get => new global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Relationships.Tags.TagsRequestBuilder(PathParameters, RequestAdapter); + } + + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public RelationshipsRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/todoItems/{id}/relationships", pathParameters) + { + } + + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public RelationshipsRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/todoItems/{id}/relationships", rawUrl) + { + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/TodoItems/Item/Relationships/Tags/TagsRequestBuilder.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/TodoItems/Item/Relationships/Tags/TagsRequestBuilder.cs new file mode 100644 index 0000000000..0dc145b7f0 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/TodoItems/Item/Relationships/Tags/TagsRequestBuilder.cs @@ -0,0 +1,248 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions; +using OpenApiKiotaClientExample.GeneratedCode.Models; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Threading; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Relationships.Tags +{ + /// + /// Builds and executes requests for operations under \api\todoItems\{id}\relationships\tags + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class TagsRequestBuilder : BaseRequestBuilder + { + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public TagsRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/todoItems/{id}/relationships/tags{?query*}", pathParameters) + { + } + + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public TagsRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/todoItems/{id}/relationships/tags{?query*}", rawUrl) + { + } + + /// + /// Removes existing tags from the tags relationship of an individual todoItem. + /// + /// The request body + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 404 status code + /// When receiving a 409 status code + /// When receiving a 422 status code + public async Task DeleteAsync(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTagInRequest body, Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = ToDeleteRequestInformation(body, requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "409", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "422", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + await RequestAdapter.SendNoContentAsync(requestInfo, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Retrieves the related tag identities of an individual todoItem's tags relationship. + /// + /// A + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 404 status code + public async Task GetAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToGetRequestInformation(requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + return await RequestAdapter.SendAsync(requestInfo, global::OpenApiKiotaClientExample.GeneratedCode.Models.TagIdentifierCollectionResponseDocument.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + public async Task HeadAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToHeadRequestInformation(requestConfiguration); + await RequestAdapter.SendNoContentAsync(requestInfo, default, cancellationToken).ConfigureAwait(false); + } + + /// + /// Assigns existing tags to the tags relationship of an individual todoItem. + /// + /// The request body + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 404 status code + /// When receiving a 409 status code + /// When receiving a 422 status code + public async Task PatchAsync(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTagInRequest body, Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = ToPatchRequestInformation(body, requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "409", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "422", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + await RequestAdapter.SendNoContentAsync(requestInfo, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Adds existing tags to the tags relationship of an individual todoItem. + /// + /// The request body + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 404 status code + /// When receiving a 409 status code + /// When receiving a 422 status code + public async Task PostAsync(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTagInRequest body, Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = ToPostRequestInformation(body, requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "409", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "422", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + await RequestAdapter.SendNoContentAsync(requestInfo, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Removes existing tags from the tags relationship of an individual todoItem. + /// + /// A + /// The request body + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToDeleteRequestInformation(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTagInRequest body, Action>? requestConfiguration = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = new RequestInformation(Method.DELETE, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + requestInfo.SetContentFromParsable(RequestAdapter, "application/vnd.api+json;ext=openapi", body); + return requestInfo; + } + + /// + /// Retrieves the related tag identities of an individual todoItem's tags relationship. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToGetRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.GET, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + return requestInfo; + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToHeadRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.HEAD, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + return requestInfo; + } + + /// + /// Assigns existing tags to the tags relationship of an individual todoItem. + /// + /// A + /// The request body + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToPatchRequestInformation(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTagInRequest body, Action>? requestConfiguration = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = new RequestInformation(Method.PATCH, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + requestInfo.SetContentFromParsable(RequestAdapter, "application/vnd.api+json;ext=openapi", body); + return requestInfo; + } + + /// + /// Adds existing tags to the tags relationship of an individual todoItem. + /// + /// A + /// The request body + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToPostRequestInformation(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTagInRequest body, Action>? requestConfiguration = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = new RequestInformation(Method.POST, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + requestInfo.SetContentFromParsable(RequestAdapter, "application/vnd.api+json;ext=openapi", body); + return requestInfo; + } + + /// + /// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored. + /// + /// A + /// The raw URL to use for the request builder. + public global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Relationships.Tags.TagsRequestBuilder WithUrl(string rawUrl) + { + return new global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Relationships.Tags.TagsRequestBuilder(rawUrl, RequestAdapter); + } + + /// + /// Retrieves the related tag identities of an individual todoItem's tags relationship. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class TagsRequestBuilderGetQueryParameters + { + /// For syntax, see the documentation for the [`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class TagsRequestBuilderHeadQueryParameters + { + /// For syntax, see the documentation for the [`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/TodoItems/Item/Tags/TagsRequestBuilder.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/TodoItems/Item/Tags/TagsRequestBuilder.cs new file mode 100644 index 0000000000..bddab801c4 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/TodoItems/Item/Tags/TagsRequestBuilder.cs @@ -0,0 +1,128 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions; +using OpenApiKiotaClientExample.GeneratedCode.Models; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Threading; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Tags +{ + /// + /// Builds and executes requests for operations under \api\todoItems\{id}\tags + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class TagsRequestBuilder : BaseRequestBuilder + { + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public TagsRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/todoItems/{id}/tags{?query*}", pathParameters) + { + } + + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public TagsRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/todoItems/{id}/tags{?query*}", rawUrl) + { + } + + /// + /// Retrieves the related tags of an individual todoItem's tags relationship. + /// + /// A + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 404 status code + public async Task GetAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToGetRequestInformation(requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + return await RequestAdapter.SendAsync(requestInfo, global::OpenApiKiotaClientExample.GeneratedCode.Models.TagCollectionResponseDocument.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + public async Task HeadAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToHeadRequestInformation(requestConfiguration); + await RequestAdapter.SendNoContentAsync(requestInfo, default, cancellationToken).ConfigureAwait(false); + } + + /// + /// Retrieves the related tags of an individual todoItem's tags relationship. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToGetRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.GET, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + return requestInfo; + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToHeadRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.HEAD, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + return requestInfo; + } + + /// + /// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored. + /// + /// A + /// The raw URL to use for the request builder. + public global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Tags.TagsRequestBuilder WithUrl(string rawUrl) + { + return new global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Tags.TagsRequestBuilder(rawUrl, RequestAdapter); + } + + /// + /// Retrieves the related tags of an individual todoItem's tags relationship. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class TagsRequestBuilderGetQueryParameters + { + /// For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class TagsRequestBuilderHeadQueryParameters + { + /// For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/TodoItems/Item/TodoItemsItemRequestBuilder.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/TodoItems/Item/TodoItemsItemRequestBuilder.cs new file mode 100644 index 0000000000..2383afe1a0 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/TodoItems/Item/TodoItemsItemRequestBuilder.cs @@ -0,0 +1,237 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions; +using OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Assignee; +using OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Owner; +using OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Relationships; +using OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Tags; +using OpenApiKiotaClientExample.GeneratedCode.Models; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Threading; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item +{ + /// + /// Builds and executes requests for operations under \api\todoItems\{id} + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class TodoItemsItemRequestBuilder : BaseRequestBuilder + { + /// The assignee property + public global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Assignee.AssigneeRequestBuilder Assignee + { + get => new global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Assignee.AssigneeRequestBuilder(PathParameters, RequestAdapter); + } + + /// The owner property + public global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Owner.OwnerRequestBuilder Owner + { + get => new global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Owner.OwnerRequestBuilder(PathParameters, RequestAdapter); + } + + /// The relationships property + public global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Relationships.RelationshipsRequestBuilder Relationships + { + get => new global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Relationships.RelationshipsRequestBuilder(PathParameters, RequestAdapter); + } + + /// The tags property + public global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Tags.TagsRequestBuilder Tags + { + get => new global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.Tags.TagsRequestBuilder(PathParameters, RequestAdapter); + } + + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public TodoItemsItemRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/todoItems/{id}{?query*}", pathParameters) + { + } + + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public TodoItemsItemRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/todoItems/{id}{?query*}", rawUrl) + { + } + + /// + /// Deletes an existing todoItem by its identifier. + /// + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 404 status code + public async Task DeleteAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToDeleteRequestInformation(requestConfiguration); + var errorMapping = new Dictionary> + { + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + await RequestAdapter.SendNoContentAsync(requestInfo, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Retrieves an individual todoItem by its identifier. + /// + /// A + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 404 status code + public async Task GetAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToGetRequestInformation(requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + return await RequestAdapter.SendAsync(requestInfo, global::OpenApiKiotaClientExample.GeneratedCode.Models.PrimaryTodoItemResponseDocument.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + public async Task HeadAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToHeadRequestInformation(requestConfiguration); + await RequestAdapter.SendNoContentAsync(requestInfo, default, cancellationToken).ConfigureAwait(false); + } + + /// + /// Updates an existing todoItem. + /// + /// A + /// The request body + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 404 status code + /// When receiving a 409 status code + /// When receiving a 422 status code + public async Task PatchAsync(global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateTodoItemRequestDocument body, Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = ToPatchRequestInformation(body, requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "409", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "422", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + return await RequestAdapter.SendAsync(requestInfo, global::OpenApiKiotaClientExample.GeneratedCode.Models.PrimaryTodoItemResponseDocument.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Deletes an existing todoItem by its identifier. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToDeleteRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.DELETE, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + return requestInfo; + } + + /// + /// Retrieves an individual todoItem by its identifier. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToGetRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.GET, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + return requestInfo; + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToHeadRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.HEAD, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + return requestInfo; + } + + /// + /// Updates an existing todoItem. + /// + /// A + /// The request body + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToPatchRequestInformation(global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateTodoItemRequestDocument body, Action>? requestConfiguration = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = new RequestInformation(Method.PATCH, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + requestInfo.SetContentFromParsable(RequestAdapter, "application/vnd.api+json;ext=openapi", body); + return requestInfo; + } + + /// + /// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored. + /// + /// A + /// The raw URL to use for the request builder. + public global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.TodoItemsItemRequestBuilder WithUrl(string rawUrl) + { + return new global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.TodoItemsItemRequestBuilder(rawUrl, RequestAdapter); + } + + /// + /// Retrieves an individual todoItem by its identifier. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class TodoItemsItemRequestBuilderGetQueryParameters + { + /// For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class TodoItemsItemRequestBuilderHeadQueryParameters + { + /// For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + + /// + /// Updates an existing todoItem. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class TodoItemsItemRequestBuilderPatchQueryParameters + { + /// For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/TodoItems/TodoItemsRequestBuilder.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/TodoItems/TodoItemsRequestBuilder.cs new file mode 100644 index 0000000000..bc3feeaf22 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Api/TodoItems/TodoItemsRequestBuilder.cs @@ -0,0 +1,194 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions; +using OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item; +using OpenApiKiotaClientExample.GeneratedCode.Models; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Threading; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems +{ + /// + /// Builds and executes requests for operations under \api\todoItems + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class TodoItemsRequestBuilder : BaseRequestBuilder + { + /// Gets an item from the OpenApiKiotaClientExample.GeneratedCode.api.todoItems.item collection + /// The identifier of the todoItem to retrieve. + /// A + public global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.TodoItemsItemRequestBuilder this[string position] + { + get + { + var urlTplParams = new Dictionary(PathParameters); + urlTplParams.Add("id", position); + return new global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.Item.TodoItemsItemRequestBuilder(urlTplParams, RequestAdapter); + } + } + + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public TodoItemsRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/todoItems{?query*}", pathParameters) + { + } + + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public TodoItemsRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/api/todoItems{?query*}", rawUrl) + { + } + + /// + /// Retrieves a collection of todoItems. + /// + /// A + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + public async Task GetAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToGetRequestInformation(requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + return await RequestAdapter.SendAsync(requestInfo, global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemCollectionResponseDocument.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + public async Task HeadAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + var requestInfo = ToHeadRequestInformation(requestConfiguration); + await RequestAdapter.SendNoContentAsync(requestInfo, default, cancellationToken).ConfigureAwait(false); + } + + /// + /// Creates a new todoItem. + /// + /// A + /// The request body + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 403 status code + /// When receiving a 404 status code + /// When receiving a 409 status code + /// When receiving a 422 status code + public async Task PostAsync(global::OpenApiKiotaClientExample.GeneratedCode.Models.CreateTodoItemRequestDocument body, Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = ToPostRequestInformation(body, requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "403", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "404", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "409", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + { "422", global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument.CreateFromDiscriminatorValue }, + }; + return await RequestAdapter.SendAsync(requestInfo, global::OpenApiKiotaClientExample.GeneratedCode.Models.PrimaryTodoItemResponseDocument.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false); + } + + /// + /// Retrieves a collection of todoItems. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToGetRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.GET, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + return requestInfo; + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToHeadRequestInformation(Action>? requestConfiguration = default) + { + var requestInfo = new RequestInformation(Method.HEAD, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + return requestInfo; + } + + /// + /// Creates a new todoItem. + /// + /// A + /// The request body + /// Configuration for the request such as headers, query parameters, and middleware options. + public RequestInformation ToPostRequestInformation(global::OpenApiKiotaClientExample.GeneratedCode.Models.CreateTodoItemRequestDocument body, Action>? requestConfiguration = default) + { + _ = body ?? throw new ArgumentNullException(nameof(body)); + var requestInfo = new RequestInformation(Method.POST, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/vnd.api+json;ext=openapi"); + requestInfo.SetContentFromParsable(RequestAdapter, "application/vnd.api+json;ext=openapi", body); + return requestInfo; + } + + /// + /// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored. + /// + /// A + /// The raw URL to use for the request builder. + public global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.TodoItemsRequestBuilder WithUrl(string rawUrl) + { + return new global::OpenApiKiotaClientExample.GeneratedCode.Api.TodoItems.TodoItemsRequestBuilder(rawUrl, RequestAdapter); + } + + /// + /// Retrieves a collection of todoItems. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class TodoItemsRequestBuilderGetQueryParameters + { + /// For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + + /// + /// Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class TodoItemsRequestBuilderHeadQueryParameters + { + /// For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + + /// + /// Creates a new todoItem. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class TodoItemsRequestBuilderPostQueryParameters + { + /// For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters. + [QueryParameter("query")] + public string? Query { get; set; } + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/ExampleApiClient.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/ExampleApiClient.cs new file mode 100644 index 0000000000..15ea897045 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/ExampleApiClient.cs @@ -0,0 +1,54 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Store; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Serialization.Form; +using Microsoft.Kiota.Serialization.Json; +using Microsoft.Kiota.Serialization.Multipart; +using Microsoft.Kiota.Serialization.Text; +using OpenApiKiotaClientExample.GeneratedCode.Api; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode +{ + /// + /// The main entry point of the SDK, exposes the configuration and the fluent API. + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class ExampleApiClient : BaseRequestBuilder + { + /// The api property + public global::OpenApiKiotaClientExample.GeneratedCode.Api.ApiRequestBuilder Api + { + get => new global::OpenApiKiotaClientExample.GeneratedCode.Api.ApiRequestBuilder(PathParameters, RequestAdapter); + } + + /// + /// Instantiates a new and sets the default values. + /// + /// The backing store to use for the models. + /// The request adapter to use to execute the requests. + public ExampleApiClient(IRequestAdapter requestAdapter, IBackingStoreFactory backingStore = default) : base(requestAdapter, "{+baseurl}", new Dictionary()) + { + ApiClientBuilder.RegisterDefaultSerializer(); + ApiClientBuilder.RegisterDefaultSerializer(); + ApiClientBuilder.RegisterDefaultSerializer(); + ApiClientBuilder.RegisterDefaultSerializer(); + ApiClientBuilder.RegisterDefaultDeserializer(); + ApiClientBuilder.RegisterDefaultDeserializer(); + ApiClientBuilder.RegisterDefaultDeserializer(); + if (string.IsNullOrEmpty(RequestAdapter.BaseUrl)) + { + RequestAdapter.BaseUrl = "https://localhost:44340"; + } + PathParameters.TryAdd("baseurl", RequestAdapter.BaseUrl); + RequestAdapter.EnableBackingStore(backingStore); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AddOperationCode.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AddOperationCode.cs new file mode 100644 index 0000000000..eb7b72025a --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AddOperationCode.cs @@ -0,0 +1,18 @@ +// +#nullable enable +#pragma warning disable CS8625 +using System.Runtime.Serialization; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public enum AddOperationCode + #pragma warning restore CS1591 + { + [EnumMember(Value = "add")] + #pragma warning disable CS1591 + Add, + #pragma warning restore CS1591 + } +} diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AddToPersonAssignedTodoItemsRelationshipOperation.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AddToPersonAssignedTodoItemsRelationshipOperation.cs new file mode 100644 index 0000000000..e45dc51ebf --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AddToPersonAssignedTodoItemsRelationshipOperation.cs @@ -0,0 +1,77 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class AddToPersonAssignedTodoItemsRelationshipOperation : global::OpenApiKiotaClientExample.GeneratedCode.Models.AtomicOperation, IParsable + #pragma warning restore CS1591 + { + /// The data property + public List? Data + { + get { return BackingStore?.Get?>("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The op property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.AddOperationCode? Op + { + get { return BackingStore?.Get("op"); } + set { BackingStore?.Set("op", value); } + } + + /// The ref property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonAssignedTodoItemsRelationshipIdentifier? Ref + { + get { return BackingStore?.Get("ref"); } + set { BackingStore?.Set("ref", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.AddToPersonAssignedTodoItemsRelationshipOperation CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.AddToPersonAssignedTodoItemsRelationshipOperation(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "data", n => { Data = n.GetCollectionOfObjectValues(global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemIdentifierInRequest.CreateFromDiscriminatorValue)?.AsList(); } }, + { "op", n => { Op = n.GetEnumValue(); } }, + { "ref", n => { Ref = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonAssignedTodoItemsRelationshipIdentifier.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteCollectionOfObjectValues("data", Data); + writer.WriteEnumValue("op", Op); + writer.WriteObjectValue("ref", Ref); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AddToPersonOwnedTodoItemsRelationshipOperation.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AddToPersonOwnedTodoItemsRelationshipOperation.cs new file mode 100644 index 0000000000..23f7ef0320 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AddToPersonOwnedTodoItemsRelationshipOperation.cs @@ -0,0 +1,77 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class AddToPersonOwnedTodoItemsRelationshipOperation : global::OpenApiKiotaClientExample.GeneratedCode.Models.AtomicOperation, IParsable + #pragma warning restore CS1591 + { + /// The data property + public List? Data + { + get { return BackingStore?.Get?>("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The op property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.AddOperationCode? Op + { + get { return BackingStore?.Get("op"); } + set { BackingStore?.Set("op", value); } + } + + /// The ref property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonOwnedTodoItemsRelationshipIdentifier? Ref + { + get { return BackingStore?.Get("ref"); } + set { BackingStore?.Set("ref", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.AddToPersonOwnedTodoItemsRelationshipOperation CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.AddToPersonOwnedTodoItemsRelationshipOperation(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "data", n => { Data = n.GetCollectionOfObjectValues(global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemIdentifierInRequest.CreateFromDiscriminatorValue)?.AsList(); } }, + { "op", n => { Op = n.GetEnumValue(); } }, + { "ref", n => { Ref = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonOwnedTodoItemsRelationshipIdentifier.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteCollectionOfObjectValues("data", Data); + writer.WriteEnumValue("op", Op); + writer.WriteObjectValue("ref", Ref); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AddToTagTodoItemsRelationshipOperation.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AddToTagTodoItemsRelationshipOperation.cs new file mode 100644 index 0000000000..960e6aa8dc --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AddToTagTodoItemsRelationshipOperation.cs @@ -0,0 +1,77 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class AddToTagTodoItemsRelationshipOperation : global::OpenApiKiotaClientExample.GeneratedCode.Models.AtomicOperation, IParsable + #pragma warning restore CS1591 + { + /// The data property + public List? Data + { + get { return BackingStore?.Get?>("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The op property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.AddOperationCode? Op + { + get { return BackingStore?.Get("op"); } + set { BackingStore?.Set("op", value); } + } + + /// The ref property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.TagTodoItemsRelationshipIdentifier? Ref + { + get { return BackingStore?.Get("ref"); } + set { BackingStore?.Set("ref", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.AddToTagTodoItemsRelationshipOperation CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.AddToTagTodoItemsRelationshipOperation(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "data", n => { Data = n.GetCollectionOfObjectValues(global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemIdentifierInRequest.CreateFromDiscriminatorValue)?.AsList(); } }, + { "op", n => { Op = n.GetEnumValue(); } }, + { "ref", n => { Ref = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.TagTodoItemsRelationshipIdentifier.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteCollectionOfObjectValues("data", Data); + writer.WriteEnumValue("op", Op); + writer.WriteObjectValue("ref", Ref); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AddToTodoItemTagsRelationshipOperation.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AddToTodoItemTagsRelationshipOperation.cs new file mode 100644 index 0000000000..69f97a3707 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AddToTodoItemTagsRelationshipOperation.cs @@ -0,0 +1,77 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class AddToTodoItemTagsRelationshipOperation : global::OpenApiKiotaClientExample.GeneratedCode.Models.AtomicOperation, IParsable + #pragma warning restore CS1591 + { + /// The data property + public List? Data + { + get { return BackingStore?.Get?>("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The op property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.AddOperationCode? Op + { + get { return BackingStore?.Get("op"); } + set { BackingStore?.Set("op", value); } + } + + /// The ref property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemTagsRelationshipIdentifier? Ref + { + get { return BackingStore?.Get("ref"); } + set { BackingStore?.Set("ref", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.AddToTodoItemTagsRelationshipOperation CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.AddToTodoItemTagsRelationshipOperation(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "data", n => { Data = n.GetCollectionOfObjectValues(global::OpenApiKiotaClientExample.GeneratedCode.Models.TagIdentifierInRequest.CreateFromDiscriminatorValue)?.AsList(); } }, + { "op", n => { Op = n.GetEnumValue(); } }, + { "ref", n => { Ref = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemTagsRelationshipIdentifier.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteCollectionOfObjectValues("data", Data); + writer.WriteEnumValue("op", Op); + writer.WriteObjectValue("ref", Ref); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AtomicOperation.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AtomicOperation.cs new file mode 100644 index 0000000000..3703a87e02 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AtomicOperation.cs @@ -0,0 +1,106 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class AtomicOperation : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// The openapiDiscriminator property + public string? OpenapiDiscriminator + { + get { return BackingStore?.Get("openapi:discriminator"); } + set { BackingStore?.Set("openapi:discriminator", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public AtomicOperation() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.AtomicOperation CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + var mappingValue = parseNode.GetChildNode("openapi:discriminator")?.GetStringValue(); + return mappingValue switch + { + "addPerson" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.CreatePersonOperation(), + "addTag" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.CreateTagOperation(), + "addTodoItem" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.CreateTodoItemOperation(), + "addToPersonAssignedTodoItems" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.AddToPersonAssignedTodoItemsRelationshipOperation(), + "addToPersonOwnedTodoItems" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.AddToPersonOwnedTodoItemsRelationshipOperation(), + "addToTagTodoItems" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.AddToTagTodoItemsRelationshipOperation(), + "addToTodoItemTags" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.AddToTodoItemTagsRelationshipOperation(), + "removeFromPersonAssignedTodoItems" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.RemoveFromPersonAssignedTodoItemsRelationshipOperation(), + "removeFromPersonOwnedTodoItems" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.RemoveFromPersonOwnedTodoItemsRelationshipOperation(), + "removeFromTagTodoItems" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.RemoveFromTagTodoItemsRelationshipOperation(), + "removeFromTodoItemTags" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.RemoveFromTodoItemTagsRelationshipOperation(), + "removePerson" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.DeletePersonOperation(), + "removeTag" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.DeleteTagOperation(), + "removeTodoItem" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.DeleteTodoItemOperation(), + "updatePerson" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdatePersonOperation(), + "updatePersonAssignedTodoItems" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdatePersonAssignedTodoItemsRelationshipOperation(), + "updatePersonOwnedTodoItems" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdatePersonOwnedTodoItemsRelationshipOperation(), + "updateTag" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateTagOperation(), + "updateTagTodoItems" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateTagTodoItemsRelationshipOperation(), + "updateTodoItem" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateTodoItemOperation(), + "updateTodoItemAssignee" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateTodoItemAssigneeRelationshipOperation(), + "updateTodoItemOwner" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateTodoItemOwnerRelationshipOperation(), + "updateTodoItemTags" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateTodoItemTagsRelationshipOperation(), + _ => new global::OpenApiKiotaClientExample.GeneratedCode.Models.AtomicOperation(), + }; + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + { "openapi:discriminator", n => { OpenapiDiscriminator = n.GetStringValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteObjectValue("meta", Meta); + writer.WriteStringValue("openapi:discriminator", OpenapiDiscriminator); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AtomicResult.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AtomicResult.cs new file mode 100644 index 0000000000..8be7f5afd5 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AtomicResult.cs @@ -0,0 +1,79 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class AtomicResult : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The data property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceInResponse? Data + { + get { return BackingStore?.Get("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public AtomicResult() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.AtomicResult CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.AtomicResult(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "data", n => { Data = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceInResponse.CreateFromDiscriminatorValue); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteObjectValue("data", Data); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInCreatePersonRequest.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInCreatePersonRequest.cs new file mode 100644 index 0000000000..b26154383a --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInCreatePersonRequest.cs @@ -0,0 +1,68 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class AttributesInCreatePersonRequest : global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInCreateRequest, IParsable + #pragma warning restore CS1591 + { + /// The firstName property + public string? FirstName + { + get { return BackingStore?.Get("firstName"); } + set { BackingStore?.Set("firstName", value); } + } + + /// The lastName property + public string? LastName + { + get { return BackingStore?.Get("lastName"); } + set { BackingStore?.Set("lastName", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInCreatePersonRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInCreatePersonRequest(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "firstName", n => { FirstName = n.GetStringValue(); } }, + { "lastName", n => { LastName = n.GetStringValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteStringValue("firstName", FirstName); + writer.WriteStringValue("lastName", LastName); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInCreateRequest.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInCreateRequest.cs new file mode 100644 index 0000000000..19fb0fdd8b --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInCreateRequest.cs @@ -0,0 +1,77 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class AttributesInCreateRequest : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The openapiDiscriminator property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceType? OpenapiDiscriminator + { + get { return BackingStore?.Get("openapi:discriminator"); } + set { BackingStore?.Set("openapi:discriminator", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public AttributesInCreateRequest() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInCreateRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + var mappingValue = parseNode.GetChildNode("openapi:discriminator")?.GetStringValue(); + return mappingValue switch + { + "people" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInCreatePersonRequest(), + "tags" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInCreateTagRequest(), + "todoItems" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInCreateTodoItemRequest(), + _ => new global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInCreateRequest(), + }; + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "openapi:discriminator", n => { OpenapiDiscriminator = n.GetEnumValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteEnumValue("openapi:discriminator", OpenapiDiscriminator); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInCreateTagRequest.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInCreateTagRequest.cs new file mode 100644 index 0000000000..b191d1ad18 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInCreateTagRequest.cs @@ -0,0 +1,59 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class AttributesInCreateTagRequest : global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInCreateRequest, IParsable + #pragma warning restore CS1591 + { + /// The name property + public string? Name + { + get { return BackingStore?.Get("name"); } + set { BackingStore?.Set("name", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInCreateTagRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInCreateTagRequest(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "name", n => { Name = n.GetStringValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteStringValue("name", Name); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInCreateTodoItemRequest.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInCreateTodoItemRequest.cs new file mode 100644 index 0000000000..d783a50dfa --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInCreateTodoItemRequest.cs @@ -0,0 +1,77 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class AttributesInCreateTodoItemRequest : global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInCreateRequest, IParsable + #pragma warning restore CS1591 + { + /// The description property + public string? Description + { + get { return BackingStore?.Get("description"); } + set { BackingStore?.Set("description", value); } + } + + /// The durationInHours property + public long? DurationInHours + { + get { return BackingStore?.Get("durationInHours"); } + set { BackingStore?.Set("durationInHours", value); } + } + + /// The priority property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemPriority? Priority + { + get { return BackingStore?.Get("priority"); } + set { BackingStore?.Set("priority", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInCreateTodoItemRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInCreateTodoItemRequest(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "description", n => { Description = n.GetStringValue(); } }, + { "durationInHours", n => { DurationInHours = n.GetLongValue(); } }, + { "priority", n => { Priority = n.GetEnumValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteStringValue("description", Description); + writer.WriteLongValue("durationInHours", DurationInHours); + writer.WriteEnumValue("priority", Priority); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInPersonResponse.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInPersonResponse.cs new file mode 100644 index 0000000000..ab1156dd4a --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInPersonResponse.cs @@ -0,0 +1,76 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class AttributesInPersonResponse : global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInResponse, IParsable + #pragma warning restore CS1591 + { + /// The displayName property + public string? DisplayName + { + get { return BackingStore?.Get("displayName"); } + set { BackingStore?.Set("displayName", value); } + } + + /// The firstName property + public string? FirstName + { + get { return BackingStore?.Get("firstName"); } + set { BackingStore?.Set("firstName", value); } + } + + /// The lastName property + public string? LastName + { + get { return BackingStore?.Get("lastName"); } + set { BackingStore?.Set("lastName", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInPersonResponse CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInPersonResponse(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "displayName", n => { DisplayName = n.GetStringValue(); } }, + { "firstName", n => { FirstName = n.GetStringValue(); } }, + { "lastName", n => { LastName = n.GetStringValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteStringValue("firstName", FirstName); + writer.WriteStringValue("lastName", LastName); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInResponse.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInResponse.cs new file mode 100644 index 0000000000..6ff4a6df92 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInResponse.cs @@ -0,0 +1,77 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class AttributesInResponse : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The openapiDiscriminator property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceType? OpenapiDiscriminator + { + get { return BackingStore?.Get("openapi:discriminator"); } + set { BackingStore?.Set("openapi:discriminator", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public AttributesInResponse() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInResponse CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + var mappingValue = parseNode.GetChildNode("openapi:discriminator")?.GetStringValue(); + return mappingValue switch + { + "people" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInPersonResponse(), + "tags" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInTagResponse(), + "todoItems" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInTodoItemResponse(), + _ => new global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInResponse(), + }; + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "openapi:discriminator", n => { OpenapiDiscriminator = n.GetEnumValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteEnumValue("openapi:discriminator", OpenapiDiscriminator); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInTagResponse.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInTagResponse.cs new file mode 100644 index 0000000000..1c17475540 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInTagResponse.cs @@ -0,0 +1,59 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class AttributesInTagResponse : global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInResponse, IParsable + #pragma warning restore CS1591 + { + /// The name property + public string? Name + { + get { return BackingStore?.Get("name"); } + set { BackingStore?.Set("name", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInTagResponse CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInTagResponse(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "name", n => { Name = n.GetStringValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteStringValue("name", Name); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInTodoItemResponse.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInTodoItemResponse.cs new file mode 100644 index 0000000000..e3d822bc8d --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInTodoItemResponse.cs @@ -0,0 +1,95 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class AttributesInTodoItemResponse : global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInResponse, IParsable + #pragma warning restore CS1591 + { + /// The createdAt property + public DateTimeOffset? CreatedAt + { + get { return BackingStore?.Get("createdAt"); } + set { BackingStore?.Set("createdAt", value); } + } + + /// The description property + public string? Description + { + get { return BackingStore?.Get("description"); } + set { BackingStore?.Set("description", value); } + } + + /// The durationInHours property + public long? DurationInHours + { + get { return BackingStore?.Get("durationInHours"); } + set { BackingStore?.Set("durationInHours", value); } + } + + /// The modifiedAt property + public DateTimeOffset? ModifiedAt + { + get { return BackingStore?.Get("modifiedAt"); } + set { BackingStore?.Set("modifiedAt", value); } + } + + /// The priority property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemPriority? Priority + { + get { return BackingStore?.Get("priority"); } + set { BackingStore?.Set("priority", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInTodoItemResponse CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInTodoItemResponse(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "createdAt", n => { CreatedAt = n.GetDateTimeOffsetValue(); } }, + { "description", n => { Description = n.GetStringValue(); } }, + { "durationInHours", n => { DurationInHours = n.GetLongValue(); } }, + { "modifiedAt", n => { ModifiedAt = n.GetDateTimeOffsetValue(); } }, + { "priority", n => { Priority = n.GetEnumValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteDateTimeOffsetValue("createdAt", CreatedAt); + writer.WriteStringValue("description", Description); + writer.WriteLongValue("durationInHours", DurationInHours); + writer.WriteDateTimeOffsetValue("modifiedAt", ModifiedAt); + writer.WriteEnumValue("priority", Priority); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInUpdatePersonRequest.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInUpdatePersonRequest.cs new file mode 100644 index 0000000000..eac162ccac --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInUpdatePersonRequest.cs @@ -0,0 +1,68 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class AttributesInUpdatePersonRequest : global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInUpdateRequest, IParsable + #pragma warning restore CS1591 + { + /// The firstName property + public string? FirstName + { + get { return BackingStore?.Get("firstName"); } + set { BackingStore?.Set("firstName", value); } + } + + /// The lastName property + public string? LastName + { + get { return BackingStore?.Get("lastName"); } + set { BackingStore?.Set("lastName", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInUpdatePersonRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInUpdatePersonRequest(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "firstName", n => { FirstName = n.GetStringValue(); } }, + { "lastName", n => { LastName = n.GetStringValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteStringValue("firstName", FirstName); + writer.WriteStringValue("lastName", LastName); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInUpdateRequest.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInUpdateRequest.cs new file mode 100644 index 0000000000..3da7558632 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInUpdateRequest.cs @@ -0,0 +1,77 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class AttributesInUpdateRequest : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The openapiDiscriminator property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceType? OpenapiDiscriminator + { + get { return BackingStore?.Get("openapi:discriminator"); } + set { BackingStore?.Set("openapi:discriminator", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public AttributesInUpdateRequest() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInUpdateRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + var mappingValue = parseNode.GetChildNode("openapi:discriminator")?.GetStringValue(); + return mappingValue switch + { + "people" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInUpdatePersonRequest(), + "tags" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInUpdateTagRequest(), + "todoItems" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInUpdateTodoItemRequest(), + _ => new global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInUpdateRequest(), + }; + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "openapi:discriminator", n => { OpenapiDiscriminator = n.GetEnumValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteEnumValue("openapi:discriminator", OpenapiDiscriminator); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInUpdateTagRequest.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInUpdateTagRequest.cs new file mode 100644 index 0000000000..4f09c54701 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInUpdateTagRequest.cs @@ -0,0 +1,59 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class AttributesInUpdateTagRequest : global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInUpdateRequest, IParsable + #pragma warning restore CS1591 + { + /// The name property + public string? Name + { + get { return BackingStore?.Get("name"); } + set { BackingStore?.Set("name", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInUpdateTagRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInUpdateTagRequest(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "name", n => { Name = n.GetStringValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteStringValue("name", Name); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInUpdateTodoItemRequest.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInUpdateTodoItemRequest.cs new file mode 100644 index 0000000000..bac8e0a4d9 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/AttributesInUpdateTodoItemRequest.cs @@ -0,0 +1,77 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class AttributesInUpdateTodoItemRequest : global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInUpdateRequest, IParsable + #pragma warning restore CS1591 + { + /// The description property + public string? Description + { + get { return BackingStore?.Get("description"); } + set { BackingStore?.Set("description", value); } + } + + /// The durationInHours property + public long? DurationInHours + { + get { return BackingStore?.Get("durationInHours"); } + set { BackingStore?.Set("durationInHours", value); } + } + + /// The priority property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemPriority? Priority + { + get { return BackingStore?.Get("priority"); } + set { BackingStore?.Set("priority", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInUpdateTodoItemRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInUpdateTodoItemRequest(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "description", n => { Description = n.GetStringValue(); } }, + { "durationInHours", n => { DurationInHours = n.GetLongValue(); } }, + { "priority", n => { Priority = n.GetEnumValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteStringValue("description", Description); + writer.WriteLongValue("durationInHours", DurationInHours); + writer.WriteEnumValue("priority", Priority); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/CreatePersonOperation.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/CreatePersonOperation.cs new file mode 100644 index 0000000000..c3c532fe55 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/CreatePersonOperation.cs @@ -0,0 +1,68 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class CreatePersonOperation : global::OpenApiKiotaClientExample.GeneratedCode.Models.AtomicOperation, IParsable + #pragma warning restore CS1591 + { + /// The data property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInCreatePersonRequest? Data + { + get { return BackingStore?.Get("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The op property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.AddOperationCode? Op + { + get { return BackingStore?.Get("op"); } + set { BackingStore?.Set("op", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.CreatePersonOperation CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.CreatePersonOperation(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "data", n => { Data = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInCreatePersonRequest.CreateFromDiscriminatorValue); } }, + { "op", n => { Op = n.GetEnumValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteObjectValue("data", Data); + writer.WriteEnumValue("op", Op); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/CreatePersonRequestDocument.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/CreatePersonRequestDocument.cs new file mode 100644 index 0000000000..506e9b2d18 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/CreatePersonRequestDocument.cs @@ -0,0 +1,79 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class CreatePersonRequestDocument : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The data property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInCreatePersonRequest? Data + { + get { return BackingStore?.Get("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public CreatePersonRequestDocument() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.CreatePersonRequestDocument CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.CreatePersonRequestDocument(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "data", n => { Data = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInCreatePersonRequest.CreateFromDiscriminatorValue); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteObjectValue("data", Data); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/CreateTagOperation.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/CreateTagOperation.cs new file mode 100644 index 0000000000..18304dc659 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/CreateTagOperation.cs @@ -0,0 +1,68 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class CreateTagOperation : global::OpenApiKiotaClientExample.GeneratedCode.Models.AtomicOperation, IParsable + #pragma warning restore CS1591 + { + /// The data property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInCreateTagRequest? Data + { + get { return BackingStore?.Get("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The op property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.AddOperationCode? Op + { + get { return BackingStore?.Get("op"); } + set { BackingStore?.Set("op", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.CreateTagOperation CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.CreateTagOperation(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "data", n => { Data = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInCreateTagRequest.CreateFromDiscriminatorValue); } }, + { "op", n => { Op = n.GetEnumValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteObjectValue("data", Data); + writer.WriteEnumValue("op", Op); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/CreateTagRequestDocument.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/CreateTagRequestDocument.cs new file mode 100644 index 0000000000..f339b44870 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/CreateTagRequestDocument.cs @@ -0,0 +1,79 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class CreateTagRequestDocument : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The data property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInCreateTagRequest? Data + { + get { return BackingStore?.Get("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public CreateTagRequestDocument() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.CreateTagRequestDocument CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.CreateTagRequestDocument(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "data", n => { Data = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInCreateTagRequest.CreateFromDiscriminatorValue); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteObjectValue("data", Data); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/CreateTodoItemOperation.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/CreateTodoItemOperation.cs new file mode 100644 index 0000000000..a879dba99a --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/CreateTodoItemOperation.cs @@ -0,0 +1,68 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class CreateTodoItemOperation : global::OpenApiKiotaClientExample.GeneratedCode.Models.AtomicOperation, IParsable + #pragma warning restore CS1591 + { + /// The data property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInCreateTodoItemRequest? Data + { + get { return BackingStore?.Get("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The op property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.AddOperationCode? Op + { + get { return BackingStore?.Get("op"); } + set { BackingStore?.Set("op", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.CreateTodoItemOperation CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.CreateTodoItemOperation(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "data", n => { Data = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInCreateTodoItemRequest.CreateFromDiscriminatorValue); } }, + { "op", n => { Op = n.GetEnumValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteObjectValue("data", Data); + writer.WriteEnumValue("op", Op); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/CreateTodoItemRequestDocument.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/CreateTodoItemRequestDocument.cs new file mode 100644 index 0000000000..d3857f3ef4 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/CreateTodoItemRequestDocument.cs @@ -0,0 +1,79 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class CreateTodoItemRequestDocument : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The data property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInCreateTodoItemRequest? Data + { + get { return BackingStore?.Get("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public CreateTodoItemRequestDocument() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.CreateTodoItemRequestDocument CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.CreateTodoItemRequestDocument(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "data", n => { Data = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInCreateTodoItemRequest.CreateFromDiscriminatorValue); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteObjectValue("data", Data); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DataInCreatePersonRequest.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DataInCreatePersonRequest.cs new file mode 100644 index 0000000000..a73695584b --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DataInCreatePersonRequest.cs @@ -0,0 +1,77 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class DataInCreatePersonRequest : global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceInCreateRequest, IParsable + #pragma warning restore CS1591 + { + /// The attributes property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInCreatePersonRequest? Attributes + { + get { return BackingStore?.Get("attributes"); } + set { BackingStore?.Set("attributes", value); } + } + + /// The lid property + public string? Lid + { + get { return BackingStore?.Get("lid"); } + set { BackingStore?.Set("lid", value); } + } + + /// The relationships property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInCreatePersonRequest? Relationships + { + get { return BackingStore?.Get("relationships"); } + set { BackingStore?.Set("relationships", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInCreatePersonRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInCreatePersonRequest(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "attributes", n => { Attributes = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInCreatePersonRequest.CreateFromDiscriminatorValue); } }, + { "lid", n => { Lid = n.GetStringValue(); } }, + { "relationships", n => { Relationships = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInCreatePersonRequest.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteObjectValue("attributes", Attributes); + writer.WriteStringValue("lid", Lid); + writer.WriteObjectValue("relationships", Relationships); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DataInCreateTagRequest.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DataInCreateTagRequest.cs new file mode 100644 index 0000000000..705018cfa6 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DataInCreateTagRequest.cs @@ -0,0 +1,77 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class DataInCreateTagRequest : global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceInCreateRequest, IParsable + #pragma warning restore CS1591 + { + /// The attributes property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInCreateTagRequest? Attributes + { + get { return BackingStore?.Get("attributes"); } + set { BackingStore?.Set("attributes", value); } + } + + /// The lid property + public string? Lid + { + get { return BackingStore?.Get("lid"); } + set { BackingStore?.Set("lid", value); } + } + + /// The relationships property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInCreateTagRequest? Relationships + { + get { return BackingStore?.Get("relationships"); } + set { BackingStore?.Set("relationships", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInCreateTagRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInCreateTagRequest(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "attributes", n => { Attributes = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInCreateTagRequest.CreateFromDiscriminatorValue); } }, + { "lid", n => { Lid = n.GetStringValue(); } }, + { "relationships", n => { Relationships = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInCreateTagRequest.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteObjectValue("attributes", Attributes); + writer.WriteStringValue("lid", Lid); + writer.WriteObjectValue("relationships", Relationships); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DataInCreateTodoItemRequest.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DataInCreateTodoItemRequest.cs new file mode 100644 index 0000000000..e1493f92ca --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DataInCreateTodoItemRequest.cs @@ -0,0 +1,77 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class DataInCreateTodoItemRequest : global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceInCreateRequest, IParsable + #pragma warning restore CS1591 + { + /// The attributes property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInCreateTodoItemRequest? Attributes + { + get { return BackingStore?.Get("attributes"); } + set { BackingStore?.Set("attributes", value); } + } + + /// The lid property + public string? Lid + { + get { return BackingStore?.Get("lid"); } + set { BackingStore?.Set("lid", value); } + } + + /// The relationships property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInCreateTodoItemRequest? Relationships + { + get { return BackingStore?.Get("relationships"); } + set { BackingStore?.Set("relationships", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInCreateTodoItemRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInCreateTodoItemRequest(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "attributes", n => { Attributes = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInCreateTodoItemRequest.CreateFromDiscriminatorValue); } }, + { "lid", n => { Lid = n.GetStringValue(); } }, + { "relationships", n => { Relationships = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInCreateTodoItemRequest.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteObjectValue("attributes", Attributes); + writer.WriteStringValue("lid", Lid); + writer.WriteObjectValue("relationships", Relationships); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DataInPersonResponse.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DataInPersonResponse.cs new file mode 100644 index 0000000000..102783642e --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DataInPersonResponse.cs @@ -0,0 +1,86 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class DataInPersonResponse : global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceInResponse, IParsable + #pragma warning restore CS1591 + { + /// The attributes property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInPersonResponse? Attributes + { + get { return BackingStore?.Get("attributes"); } + set { BackingStore?.Set("attributes", value); } + } + + /// The id property + public string? Id + { + get { return BackingStore?.Get("id"); } + set { BackingStore?.Set("id", value); } + } + + /// The links property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceLinks? Links + { + get { return BackingStore?.Get("links"); } + set { BackingStore?.Set("links", value); } + } + + /// The relationships property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInPersonResponse? Relationships + { + get { return BackingStore?.Get("relationships"); } + set { BackingStore?.Set("relationships", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInPersonResponse CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInPersonResponse(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "attributes", n => { Attributes = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInPersonResponse.CreateFromDiscriminatorValue); } }, + { "id", n => { Id = n.GetStringValue(); } }, + { "links", n => { Links = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceLinks.CreateFromDiscriminatorValue); } }, + { "relationships", n => { Relationships = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInPersonResponse.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteObjectValue("attributes", Attributes); + writer.WriteStringValue("id", Id); + writer.WriteObjectValue("links", Links); + writer.WriteObjectValue("relationships", Relationships); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DataInTagResponse.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DataInTagResponse.cs new file mode 100644 index 0000000000..d32e8dd080 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DataInTagResponse.cs @@ -0,0 +1,86 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class DataInTagResponse : global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceInResponse, IParsable + #pragma warning restore CS1591 + { + /// The attributes property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInTagResponse? Attributes + { + get { return BackingStore?.Get("attributes"); } + set { BackingStore?.Set("attributes", value); } + } + + /// The id property + public string? Id + { + get { return BackingStore?.Get("id"); } + set { BackingStore?.Set("id", value); } + } + + /// The links property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceLinks? Links + { + get { return BackingStore?.Get("links"); } + set { BackingStore?.Set("links", value); } + } + + /// The relationships property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInTagResponse? Relationships + { + get { return BackingStore?.Get("relationships"); } + set { BackingStore?.Set("relationships", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInTagResponse CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInTagResponse(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "attributes", n => { Attributes = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInTagResponse.CreateFromDiscriminatorValue); } }, + { "id", n => { Id = n.GetStringValue(); } }, + { "links", n => { Links = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceLinks.CreateFromDiscriminatorValue); } }, + { "relationships", n => { Relationships = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInTagResponse.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteObjectValue("attributes", Attributes); + writer.WriteStringValue("id", Id); + writer.WriteObjectValue("links", Links); + writer.WriteObjectValue("relationships", Relationships); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DataInTodoItemResponse.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DataInTodoItemResponse.cs new file mode 100644 index 0000000000..abe7290e36 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DataInTodoItemResponse.cs @@ -0,0 +1,86 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class DataInTodoItemResponse : global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceInResponse, IParsable + #pragma warning restore CS1591 + { + /// The attributes property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInTodoItemResponse? Attributes + { + get { return BackingStore?.Get("attributes"); } + set { BackingStore?.Set("attributes", value); } + } + + /// The id property + public string? Id + { + get { return BackingStore?.Get("id"); } + set { BackingStore?.Set("id", value); } + } + + /// The links property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceLinks? Links + { + get { return BackingStore?.Get("links"); } + set { BackingStore?.Set("links", value); } + } + + /// The relationships property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInTodoItemResponse? Relationships + { + get { return BackingStore?.Get("relationships"); } + set { BackingStore?.Set("relationships", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInTodoItemResponse CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInTodoItemResponse(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "attributes", n => { Attributes = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInTodoItemResponse.CreateFromDiscriminatorValue); } }, + { "id", n => { Id = n.GetStringValue(); } }, + { "links", n => { Links = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceLinks.CreateFromDiscriminatorValue); } }, + { "relationships", n => { Relationships = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInTodoItemResponse.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteObjectValue("attributes", Attributes); + writer.WriteStringValue("id", Id); + writer.WriteObjectValue("links", Links); + writer.WriteObjectValue("relationships", Relationships); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DataInUpdatePersonRequest.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DataInUpdatePersonRequest.cs new file mode 100644 index 0000000000..4cc76e39b0 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DataInUpdatePersonRequest.cs @@ -0,0 +1,86 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class DataInUpdatePersonRequest : global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceInUpdateRequest, IParsable + #pragma warning restore CS1591 + { + /// The attributes property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInUpdatePersonRequest? Attributes + { + get { return BackingStore?.Get("attributes"); } + set { BackingStore?.Set("attributes", value); } + } + + /// The id property + public string? Id + { + get { return BackingStore?.Get("id"); } + set { BackingStore?.Set("id", value); } + } + + /// The lid property + public string? Lid + { + get { return BackingStore?.Get("lid"); } + set { BackingStore?.Set("lid", value); } + } + + /// The relationships property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInUpdatePersonRequest? Relationships + { + get { return BackingStore?.Get("relationships"); } + set { BackingStore?.Set("relationships", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInUpdatePersonRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInUpdatePersonRequest(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "attributes", n => { Attributes = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInUpdatePersonRequest.CreateFromDiscriminatorValue); } }, + { "id", n => { Id = n.GetStringValue(); } }, + { "lid", n => { Lid = n.GetStringValue(); } }, + { "relationships", n => { Relationships = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInUpdatePersonRequest.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteObjectValue("attributes", Attributes); + writer.WriteStringValue("id", Id); + writer.WriteStringValue("lid", Lid); + writer.WriteObjectValue("relationships", Relationships); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DataInUpdateTagRequest.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DataInUpdateTagRequest.cs new file mode 100644 index 0000000000..67e8f056d0 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DataInUpdateTagRequest.cs @@ -0,0 +1,86 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class DataInUpdateTagRequest : global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceInUpdateRequest, IParsable + #pragma warning restore CS1591 + { + /// The attributes property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInUpdateTagRequest? Attributes + { + get { return BackingStore?.Get("attributes"); } + set { BackingStore?.Set("attributes", value); } + } + + /// The id property + public string? Id + { + get { return BackingStore?.Get("id"); } + set { BackingStore?.Set("id", value); } + } + + /// The lid property + public string? Lid + { + get { return BackingStore?.Get("lid"); } + set { BackingStore?.Set("lid", value); } + } + + /// The relationships property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInUpdateTagRequest? Relationships + { + get { return BackingStore?.Get("relationships"); } + set { BackingStore?.Set("relationships", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInUpdateTagRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInUpdateTagRequest(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "attributes", n => { Attributes = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInUpdateTagRequest.CreateFromDiscriminatorValue); } }, + { "id", n => { Id = n.GetStringValue(); } }, + { "lid", n => { Lid = n.GetStringValue(); } }, + { "relationships", n => { Relationships = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInUpdateTagRequest.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteObjectValue("attributes", Attributes); + writer.WriteStringValue("id", Id); + writer.WriteStringValue("lid", Lid); + writer.WriteObjectValue("relationships", Relationships); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DataInUpdateTodoItemRequest.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DataInUpdateTodoItemRequest.cs new file mode 100644 index 0000000000..f3500e1884 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DataInUpdateTodoItemRequest.cs @@ -0,0 +1,86 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class DataInUpdateTodoItemRequest : global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceInUpdateRequest, IParsable + #pragma warning restore CS1591 + { + /// The attributes property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInUpdateTodoItemRequest? Attributes + { + get { return BackingStore?.Get("attributes"); } + set { BackingStore?.Set("attributes", value); } + } + + /// The id property + public string? Id + { + get { return BackingStore?.Get("id"); } + set { BackingStore?.Set("id", value); } + } + + /// The lid property + public string? Lid + { + get { return BackingStore?.Get("lid"); } + set { BackingStore?.Set("lid", value); } + } + + /// The relationships property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInUpdateTodoItemRequest? Relationships + { + get { return BackingStore?.Get("relationships"); } + set { BackingStore?.Set("relationships", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInUpdateTodoItemRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInUpdateTodoItemRequest(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "attributes", n => { Attributes = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.AttributesInUpdateTodoItemRequest.CreateFromDiscriminatorValue); } }, + { "id", n => { Id = n.GetStringValue(); } }, + { "lid", n => { Lid = n.GetStringValue(); } }, + { "relationships", n => { Relationships = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInUpdateTodoItemRequest.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteObjectValue("attributes", Attributes); + writer.WriteStringValue("id", Id); + writer.WriteStringValue("lid", Lid); + writer.WriteObjectValue("relationships", Relationships); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DeletePersonOperation.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DeletePersonOperation.cs new file mode 100644 index 0000000000..a935cf7f39 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DeletePersonOperation.cs @@ -0,0 +1,68 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class DeletePersonOperation : global::OpenApiKiotaClientExample.GeneratedCode.Models.AtomicOperation, IParsable + #pragma warning restore CS1591 + { + /// The op property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.RemoveOperationCode? Op + { + get { return BackingStore?.Get("op"); } + set { BackingStore?.Set("op", value); } + } + + /// The ref property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonIdentifierInRequest? Ref + { + get { return BackingStore?.Get("ref"); } + set { BackingStore?.Set("ref", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.DeletePersonOperation CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.DeletePersonOperation(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "op", n => { Op = n.GetEnumValue(); } }, + { "ref", n => { Ref = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonIdentifierInRequest.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteEnumValue("op", Op); + writer.WriteObjectValue("ref", Ref); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DeleteTagOperation.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DeleteTagOperation.cs new file mode 100644 index 0000000000..2fc9ab07e4 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DeleteTagOperation.cs @@ -0,0 +1,68 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class DeleteTagOperation : global::OpenApiKiotaClientExample.GeneratedCode.Models.AtomicOperation, IParsable + #pragma warning restore CS1591 + { + /// The op property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.RemoveOperationCode? Op + { + get { return BackingStore?.Get("op"); } + set { BackingStore?.Set("op", value); } + } + + /// The ref property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.TagIdentifierInRequest? Ref + { + get { return BackingStore?.Get("ref"); } + set { BackingStore?.Set("ref", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.DeleteTagOperation CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.DeleteTagOperation(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "op", n => { Op = n.GetEnumValue(); } }, + { "ref", n => { Ref = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.TagIdentifierInRequest.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteEnumValue("op", Op); + writer.WriteObjectValue("ref", Ref); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DeleteTodoItemOperation.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DeleteTodoItemOperation.cs new file mode 100644 index 0000000000..4078148c04 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/DeleteTodoItemOperation.cs @@ -0,0 +1,68 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class DeleteTodoItemOperation : global::OpenApiKiotaClientExample.GeneratedCode.Models.AtomicOperation, IParsable + #pragma warning restore CS1591 + { + /// The op property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.RemoveOperationCode? Op + { + get { return BackingStore?.Get("op"); } + set { BackingStore?.Set("op", value); } + } + + /// The ref property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemIdentifierInRequest? Ref + { + get { return BackingStore?.Get("ref"); } + set { BackingStore?.Set("ref", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.DeleteTodoItemOperation CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.DeleteTodoItemOperation(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "op", n => { Op = n.GetEnumValue(); } }, + { "ref", n => { Ref = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemIdentifierInRequest.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteEnumValue("op", Op); + writer.WriteObjectValue("ref", Ref); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ErrorLinks.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ErrorLinks.cs new file mode 100644 index 0000000000..14988baa15 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ErrorLinks.cs @@ -0,0 +1,79 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class ErrorLinks : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// The about property + public string? About + { + get { return BackingStore?.Get("about"); } + set { BackingStore?.Set("about", value); } + } + + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The type property + public string? Type + { + get { return BackingStore?.Get("type"); } + set { BackingStore?.Set("type", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public ErrorLinks() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorLinks CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorLinks(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "about", n => { About = n.GetStringValue(); } }, + { "type", n => { Type = n.GetStringValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteStringValue("about", About); + writer.WriteStringValue("type", Type); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ErrorObject.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ErrorObject.cs new file mode 100644 index 0000000000..54b277a3e4 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ErrorObject.cs @@ -0,0 +1,133 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class ErrorObject : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The code property + public string? Code + { + get { return BackingStore?.Get("code"); } + set { BackingStore?.Set("code", value); } + } + + /// The detail property + public string? Detail + { + get { return BackingStore?.Get("detail"); } + set { BackingStore?.Set("detail", value); } + } + + /// The id property + public string? Id + { + get { return BackingStore?.Get("id"); } + set { BackingStore?.Set("id", value); } + } + + /// The links property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorLinks? Links + { + get { return BackingStore?.Get("links"); } + set { BackingStore?.Set("links", value); } + } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// The source property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorSource? Source + { + get { return BackingStore?.Get("source"); } + set { BackingStore?.Set("source", value); } + } + + /// The status property + public string? Status + { + get { return BackingStore?.Get("status"); } + set { BackingStore?.Set("status", value); } + } + + /// The title property + public string? Title + { + get { return BackingStore?.Get("title"); } + set { BackingStore?.Set("title", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public ErrorObject() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorObject CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorObject(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "code", n => { Code = n.GetStringValue(); } }, + { "detail", n => { Detail = n.GetStringValue(); } }, + { "id", n => { Id = n.GetStringValue(); } }, + { "links", n => { Links = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorLinks.CreateFromDiscriminatorValue); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + { "source", n => { Source = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorSource.CreateFromDiscriminatorValue); } }, + { "status", n => { Status = n.GetStringValue(); } }, + { "title", n => { Title = n.GetStringValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteStringValue("code", Code); + writer.WriteStringValue("detail", Detail); + writer.WriteStringValue("id", Id); + writer.WriteObjectValue("links", Links); + writer.WriteObjectValue("meta", Meta); + writer.WriteObjectValue("source", Source); + writer.WriteStringValue("status", Status); + writer.WriteStringValue("title", Title); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ErrorResponseDocument.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ErrorResponseDocument.cs new file mode 100644 index 0000000000..fa3d55ee54 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ErrorResponseDocument.cs @@ -0,0 +1,92 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using Microsoft.Kiota.Abstractions; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class ErrorResponseDocument : ApiException, IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The errors property + public List? Errors + { + get { return BackingStore?.Get?>("errors"); } + set { BackingStore?.Set("errors", value); } + } + + /// The links property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorTopLevelLinks? Links + { + get { return BackingStore?.Get("links"); } + set { BackingStore?.Set("links", value); } + } + + /// The primary error message. + public override string Message { get => base.Message; } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public ErrorResponseDocument() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorResponseDocument(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "errors", n => { Errors = n.GetCollectionOfObjectValues(global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorObject.CreateFromDiscriminatorValue)?.AsList(); } }, + { "links", n => { Links = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorTopLevelLinks.CreateFromDiscriminatorValue); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteCollectionOfObjectValues("errors", Errors); + writer.WriteObjectValue("links", Links); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ErrorSource.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ErrorSource.cs new file mode 100644 index 0000000000..2c2b7e45cc --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ErrorSource.cs @@ -0,0 +1,88 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class ErrorSource : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The header property + public string? Header + { + get { return BackingStore?.Get("header"); } + set { BackingStore?.Set("header", value); } + } + + /// The parameter property + public string? Parameter + { + get { return BackingStore?.Get("parameter"); } + set { BackingStore?.Set("parameter", value); } + } + + /// The pointer property + public string? Pointer + { + get { return BackingStore?.Get("pointer"); } + set { BackingStore?.Set("pointer", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public ErrorSource() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorSource CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorSource(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "header", n => { Header = n.GetStringValue(); } }, + { "parameter", n => { Parameter = n.GetStringValue(); } }, + { "pointer", n => { Pointer = n.GetStringValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteStringValue("header", Header); + writer.WriteStringValue("parameter", Parameter); + writer.WriteStringValue("pointer", Pointer); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ErrorTopLevelLinks.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ErrorTopLevelLinks.cs new file mode 100644 index 0000000000..bf238e2e6e --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ErrorTopLevelLinks.cs @@ -0,0 +1,79 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class ErrorTopLevelLinks : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The describedby property + public string? Describedby + { + get { return BackingStore?.Get("describedby"); } + set { BackingStore?.Set("describedby", value); } + } + + /// The self property + public string? Self + { + get { return BackingStore?.Get("self"); } + set { BackingStore?.Set("self", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public ErrorTopLevelLinks() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorTopLevelLinks CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.ErrorTopLevelLinks(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "describedby", n => { Describedby = n.GetStringValue(); } }, + { "self", n => { Self = n.GetStringValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteStringValue("describedby", Describedby); + writer.WriteStringValue("self", Self); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/IdentifierInRequest.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/IdentifierInRequest.cs new file mode 100644 index 0000000000..a997c6e0bf --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/IdentifierInRequest.cs @@ -0,0 +1,86 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class IdentifierInRequest : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// The type property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceType? Type + { + get { return BackingStore?.Get("type"); } + set { BackingStore?.Set("type", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public IdentifierInRequest() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.IdentifierInRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + var mappingValue = parseNode.GetChildNode("type")?.GetStringValue(); + return mappingValue switch + { + "people" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonIdentifierInRequest(), + "tags" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.TagIdentifierInRequest(), + "todoItems" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemIdentifierInRequest(), + _ => new global::OpenApiKiotaClientExample.GeneratedCode.Models.IdentifierInRequest(), + }; + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + { "type", n => { Type = n.GetEnumValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteObjectValue("meta", Meta); + writer.WriteEnumValue("type", Type); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/Meta.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/Meta.cs new file mode 100644 index 0000000000..1e5f665402 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/Meta.cs @@ -0,0 +1,70 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class Meta : IAdditionalDataHolder, IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores additional data not described in the OpenAPI description found when deserializing. Can be used for serialization as well. + public IDictionary AdditionalData + { + get { return BackingStore.Get>("AdditionalData") ?? new Dictionary(); } + set { BackingStore.Set("AdditionalData", value); } + } + + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// + /// Instantiates a new and sets the default values. + /// + public Meta() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + AdditionalData = new Dictionary(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteAdditionalData(AdditionalData); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/NullablePersonIdentifierResponseDocument.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/NullablePersonIdentifierResponseDocument.cs new file mode 100644 index 0000000000..245fb8a3bb --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/NullablePersonIdentifierResponseDocument.cs @@ -0,0 +1,88 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class NullablePersonIdentifierResponseDocument : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The data property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonIdentifierInResponse? Data + { + get { return BackingStore?.Get("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The links property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceIdentifierTopLevelLinks? Links + { + get { return BackingStore?.Get("links"); } + set { BackingStore?.Set("links", value); } + } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public NullablePersonIdentifierResponseDocument() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.NullablePersonIdentifierResponseDocument CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.NullablePersonIdentifierResponseDocument(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "data", n => { Data = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonIdentifierInResponse.CreateFromDiscriminatorValue); } }, + { "links", n => { Links = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceIdentifierTopLevelLinks.CreateFromDiscriminatorValue); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteObjectValue("data", Data); + writer.WriteObjectValue("links", Links); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/NullableSecondaryPersonResponseDocument.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/NullableSecondaryPersonResponseDocument.cs new file mode 100644 index 0000000000..f3a621d45c --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/NullableSecondaryPersonResponseDocument.cs @@ -0,0 +1,97 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class NullableSecondaryPersonResponseDocument : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The data property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInPersonResponse? Data + { + get { return BackingStore?.Get("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The included property + public List? Included + { + get { return BackingStore?.Get?>("included"); } + set { BackingStore?.Set("included", value); } + } + + /// The links property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceTopLevelLinks? Links + { + get { return BackingStore?.Get("links"); } + set { BackingStore?.Set("links", value); } + } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public NullableSecondaryPersonResponseDocument() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.NullableSecondaryPersonResponseDocument CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.NullableSecondaryPersonResponseDocument(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "data", n => { Data = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInPersonResponse.CreateFromDiscriminatorValue); } }, + { "included", n => { Included = n.GetCollectionOfObjectValues(global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceInResponse.CreateFromDiscriminatorValue)?.AsList(); } }, + { "links", n => { Links = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceTopLevelLinks.CreateFromDiscriminatorValue); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteObjectValue("data", Data); + writer.WriteCollectionOfObjectValues("included", Included); + writer.WriteObjectValue("links", Links); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/NullableToOnePersonInRequest.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/NullableToOnePersonInRequest.cs new file mode 100644 index 0000000000..5eab19e48b --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/NullableToOnePersonInRequest.cs @@ -0,0 +1,79 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class NullableToOnePersonInRequest : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The data property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonIdentifierInRequest? Data + { + get { return BackingStore?.Get("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public NullableToOnePersonInRequest() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.NullableToOnePersonInRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.NullableToOnePersonInRequest(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "data", n => { Data = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonIdentifierInRequest.CreateFromDiscriminatorValue); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteObjectValue("data", Data); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/NullableToOnePersonInResponse.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/NullableToOnePersonInResponse.cs new file mode 100644 index 0000000000..0de8cf3d6e --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/NullableToOnePersonInResponse.cs @@ -0,0 +1,88 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class NullableToOnePersonInResponse : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The data property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonIdentifierInResponse? Data + { + get { return BackingStore?.Get("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The links property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipLinks? Links + { + get { return BackingStore?.Get("links"); } + set { BackingStore?.Set("links", value); } + } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public NullableToOnePersonInResponse() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.NullableToOnePersonInResponse CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.NullableToOnePersonInResponse(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "data", n => { Data = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonIdentifierInResponse.CreateFromDiscriminatorValue); } }, + { "links", n => { Links = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipLinks.CreateFromDiscriminatorValue); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteObjectValue("data", Data); + writer.WriteObjectValue("links", Links); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/OperationsRequestDocument.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/OperationsRequestDocument.cs new file mode 100644 index 0000000000..3bd1932eb1 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/OperationsRequestDocument.cs @@ -0,0 +1,79 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class OperationsRequestDocument : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// The atomicOperations property + public List? AtomicOperations + { + get { return BackingStore?.Get?>("atomic:operations"); } + set { BackingStore?.Set("atomic:operations", value); } + } + + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public OperationsRequestDocument() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.OperationsRequestDocument CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.OperationsRequestDocument(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "atomic:operations", n => { AtomicOperations = n.GetCollectionOfObjectValues(global::OpenApiKiotaClientExample.GeneratedCode.Models.AtomicOperation.CreateFromDiscriminatorValue)?.AsList(); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteCollectionOfObjectValues("atomic:operations", AtomicOperations); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/OperationsResponseDocument.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/OperationsResponseDocument.cs new file mode 100644 index 0000000000..1c9588a0c5 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/OperationsResponseDocument.cs @@ -0,0 +1,88 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class OperationsResponseDocument : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// The atomicResults property + public List? AtomicResults + { + get { return BackingStore?.Get?>("atomic:results"); } + set { BackingStore?.Set("atomic:results", value); } + } + + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The links property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceTopLevelLinks? Links + { + get { return BackingStore?.Get("links"); } + set { BackingStore?.Set("links", value); } + } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public OperationsResponseDocument() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.OperationsResponseDocument CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.OperationsResponseDocument(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "atomic:results", n => { AtomicResults = n.GetCollectionOfObjectValues(global::OpenApiKiotaClientExample.GeneratedCode.Models.AtomicResult.CreateFromDiscriminatorValue)?.AsList(); } }, + { "links", n => { Links = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceTopLevelLinks.CreateFromDiscriminatorValue); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteCollectionOfObjectValues("atomic:results", AtomicResults); + writer.WriteObjectValue("links", Links); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PersonAssignedTodoItemsRelationshipIdentifier.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PersonAssignedTodoItemsRelationshipIdentifier.cs new file mode 100644 index 0000000000..e4b6d93b90 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PersonAssignedTodoItemsRelationshipIdentifier.cs @@ -0,0 +1,97 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class PersonAssignedTodoItemsRelationshipIdentifier : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The id property + public string? Id + { + get { return BackingStore?.Get("id"); } + set { BackingStore?.Set("id", value); } + } + + /// The lid property + public string? Lid + { + get { return BackingStore?.Get("lid"); } + set { BackingStore?.Set("lid", value); } + } + + /// The relationship property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonAssignedTodoItemsRelationshipName? Relationship + { + get { return BackingStore?.Get("relationship"); } + set { BackingStore?.Set("relationship", value); } + } + + /// The type property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonResourceType? Type + { + get { return BackingStore?.Get("type"); } + set { BackingStore?.Set("type", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public PersonAssignedTodoItemsRelationshipIdentifier() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonAssignedTodoItemsRelationshipIdentifier CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonAssignedTodoItemsRelationshipIdentifier(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "id", n => { Id = n.GetStringValue(); } }, + { "lid", n => { Lid = n.GetStringValue(); } }, + { "relationship", n => { Relationship = n.GetEnumValue(); } }, + { "type", n => { Type = n.GetEnumValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteStringValue("id", Id); + writer.WriteStringValue("lid", Lid); + writer.WriteEnumValue("relationship", Relationship); + writer.WriteEnumValue("type", Type); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PersonAssignedTodoItemsRelationshipName.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PersonAssignedTodoItemsRelationshipName.cs new file mode 100644 index 0000000000..bef09282ad --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PersonAssignedTodoItemsRelationshipName.cs @@ -0,0 +1,18 @@ +// +#nullable enable +#pragma warning disable CS8625 +using System.Runtime.Serialization; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public enum PersonAssignedTodoItemsRelationshipName + #pragma warning restore CS1591 + { + [EnumMember(Value = "assignedTodoItems")] + #pragma warning disable CS1591 + AssignedTodoItems, + #pragma warning restore CS1591 + } +} diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PersonCollectionResponseDocument.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PersonCollectionResponseDocument.cs new file mode 100644 index 0000000000..3b92c217c9 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PersonCollectionResponseDocument.cs @@ -0,0 +1,97 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class PersonCollectionResponseDocument : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The data property + public List? Data + { + get { return BackingStore?.Get?>("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The included property + public List? Included + { + get { return BackingStore?.Get?>("included"); } + set { BackingStore?.Set("included", value); } + } + + /// The links property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceCollectionTopLevelLinks? Links + { + get { return BackingStore?.Get("links"); } + set { BackingStore?.Set("links", value); } + } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public PersonCollectionResponseDocument() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonCollectionResponseDocument CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonCollectionResponseDocument(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "data", n => { Data = n.GetCollectionOfObjectValues(global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInPersonResponse.CreateFromDiscriminatorValue)?.AsList(); } }, + { "included", n => { Included = n.GetCollectionOfObjectValues(global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceInResponse.CreateFromDiscriminatorValue)?.AsList(); } }, + { "links", n => { Links = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceCollectionTopLevelLinks.CreateFromDiscriminatorValue); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteCollectionOfObjectValues("data", Data); + writer.WriteCollectionOfObjectValues("included", Included); + writer.WriteObjectValue("links", Links); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PersonIdentifierInRequest.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PersonIdentifierInRequest.cs new file mode 100644 index 0000000000..45aadb8951 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PersonIdentifierInRequest.cs @@ -0,0 +1,68 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class PersonIdentifierInRequest : global::OpenApiKiotaClientExample.GeneratedCode.Models.IdentifierInRequest, IParsable + #pragma warning restore CS1591 + { + /// The id property + public string? Id + { + get { return BackingStore?.Get("id"); } + set { BackingStore?.Set("id", value); } + } + + /// The lid property + public string? Lid + { + get { return BackingStore?.Get("lid"); } + set { BackingStore?.Set("lid", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonIdentifierInRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonIdentifierInRequest(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "id", n => { Id = n.GetStringValue(); } }, + { "lid", n => { Lid = n.GetStringValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteStringValue("id", Id); + writer.WriteStringValue("lid", Lid); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PersonIdentifierInResponse.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PersonIdentifierInResponse.cs new file mode 100644 index 0000000000..7c051da21f --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PersonIdentifierInResponse.cs @@ -0,0 +1,88 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class PersonIdentifierInResponse : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The id property + public string? Id + { + get { return BackingStore?.Get("id"); } + set { BackingStore?.Set("id", value); } + } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// The type property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonResourceType? Type + { + get { return BackingStore?.Get("type"); } + set { BackingStore?.Set("type", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public PersonIdentifierInResponse() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonIdentifierInResponse CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonIdentifierInResponse(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "id", n => { Id = n.GetStringValue(); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + { "type", n => { Type = n.GetEnumValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteStringValue("id", Id); + writer.WriteObjectValue("meta", Meta); + writer.WriteEnumValue("type", Type); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PersonIdentifierResponseDocument.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PersonIdentifierResponseDocument.cs new file mode 100644 index 0000000000..a7ca24727d --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PersonIdentifierResponseDocument.cs @@ -0,0 +1,88 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class PersonIdentifierResponseDocument : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The data property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonIdentifierInResponse? Data + { + get { return BackingStore?.Get("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The links property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceIdentifierTopLevelLinks? Links + { + get { return BackingStore?.Get("links"); } + set { BackingStore?.Set("links", value); } + } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public PersonIdentifierResponseDocument() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonIdentifierResponseDocument CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonIdentifierResponseDocument(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "data", n => { Data = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonIdentifierInResponse.CreateFromDiscriminatorValue); } }, + { "links", n => { Links = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceIdentifierTopLevelLinks.CreateFromDiscriminatorValue); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteObjectValue("data", Data); + writer.WriteObjectValue("links", Links); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PersonOwnedTodoItemsRelationshipIdentifier.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PersonOwnedTodoItemsRelationshipIdentifier.cs new file mode 100644 index 0000000000..62e03c01f8 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PersonOwnedTodoItemsRelationshipIdentifier.cs @@ -0,0 +1,97 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class PersonOwnedTodoItemsRelationshipIdentifier : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The id property + public string? Id + { + get { return BackingStore?.Get("id"); } + set { BackingStore?.Set("id", value); } + } + + /// The lid property + public string? Lid + { + get { return BackingStore?.Get("lid"); } + set { BackingStore?.Set("lid", value); } + } + + /// The relationship property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonOwnedTodoItemsRelationshipName? Relationship + { + get { return BackingStore?.Get("relationship"); } + set { BackingStore?.Set("relationship", value); } + } + + /// The type property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonResourceType? Type + { + get { return BackingStore?.Get("type"); } + set { BackingStore?.Set("type", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public PersonOwnedTodoItemsRelationshipIdentifier() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonOwnedTodoItemsRelationshipIdentifier CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonOwnedTodoItemsRelationshipIdentifier(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "id", n => { Id = n.GetStringValue(); } }, + { "lid", n => { Lid = n.GetStringValue(); } }, + { "relationship", n => { Relationship = n.GetEnumValue(); } }, + { "type", n => { Type = n.GetEnumValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteStringValue("id", Id); + writer.WriteStringValue("lid", Lid); + writer.WriteEnumValue("relationship", Relationship); + writer.WriteEnumValue("type", Type); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PersonOwnedTodoItemsRelationshipName.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PersonOwnedTodoItemsRelationshipName.cs new file mode 100644 index 0000000000..5c7c841a13 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PersonOwnedTodoItemsRelationshipName.cs @@ -0,0 +1,18 @@ +// +#nullable enable +#pragma warning disable CS8625 +using System.Runtime.Serialization; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public enum PersonOwnedTodoItemsRelationshipName + #pragma warning restore CS1591 + { + [EnumMember(Value = "ownedTodoItems")] + #pragma warning disable CS1591 + OwnedTodoItems, + #pragma warning restore CS1591 + } +} diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PersonResourceType.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PersonResourceType.cs new file mode 100644 index 0000000000..309e481056 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PersonResourceType.cs @@ -0,0 +1,18 @@ +// +#nullable enable +#pragma warning disable CS8625 +using System.Runtime.Serialization; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public enum PersonResourceType + #pragma warning restore CS1591 + { + [EnumMember(Value = "people")] + #pragma warning disable CS1591 + People, + #pragma warning restore CS1591 + } +} diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PrimaryPersonResponseDocument.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PrimaryPersonResponseDocument.cs new file mode 100644 index 0000000000..769819ff5d --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PrimaryPersonResponseDocument.cs @@ -0,0 +1,97 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class PrimaryPersonResponseDocument : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The data property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInPersonResponse? Data + { + get { return BackingStore?.Get("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The included property + public List? Included + { + get { return BackingStore?.Get?>("included"); } + set { BackingStore?.Set("included", value); } + } + + /// The links property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceTopLevelLinks? Links + { + get { return BackingStore?.Get("links"); } + set { BackingStore?.Set("links", value); } + } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public PrimaryPersonResponseDocument() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.PrimaryPersonResponseDocument CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.PrimaryPersonResponseDocument(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "data", n => { Data = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInPersonResponse.CreateFromDiscriminatorValue); } }, + { "included", n => { Included = n.GetCollectionOfObjectValues(global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceInResponse.CreateFromDiscriminatorValue)?.AsList(); } }, + { "links", n => { Links = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceTopLevelLinks.CreateFromDiscriminatorValue); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteObjectValue("data", Data); + writer.WriteCollectionOfObjectValues("included", Included); + writer.WriteObjectValue("links", Links); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PrimaryTagResponseDocument.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PrimaryTagResponseDocument.cs new file mode 100644 index 0000000000..56faf94dec --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PrimaryTagResponseDocument.cs @@ -0,0 +1,97 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class PrimaryTagResponseDocument : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The data property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInTagResponse? Data + { + get { return BackingStore?.Get("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The included property + public List? Included + { + get { return BackingStore?.Get?>("included"); } + set { BackingStore?.Set("included", value); } + } + + /// The links property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceTopLevelLinks? Links + { + get { return BackingStore?.Get("links"); } + set { BackingStore?.Set("links", value); } + } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public PrimaryTagResponseDocument() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.PrimaryTagResponseDocument CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.PrimaryTagResponseDocument(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "data", n => { Data = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInTagResponse.CreateFromDiscriminatorValue); } }, + { "included", n => { Included = n.GetCollectionOfObjectValues(global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceInResponse.CreateFromDiscriminatorValue)?.AsList(); } }, + { "links", n => { Links = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceTopLevelLinks.CreateFromDiscriminatorValue); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteObjectValue("data", Data); + writer.WriteCollectionOfObjectValues("included", Included); + writer.WriteObjectValue("links", Links); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PrimaryTodoItemResponseDocument.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PrimaryTodoItemResponseDocument.cs new file mode 100644 index 0000000000..2aefc28121 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/PrimaryTodoItemResponseDocument.cs @@ -0,0 +1,97 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class PrimaryTodoItemResponseDocument : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The data property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInTodoItemResponse? Data + { + get { return BackingStore?.Get("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The included property + public List? Included + { + get { return BackingStore?.Get?>("included"); } + set { BackingStore?.Set("included", value); } + } + + /// The links property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceTopLevelLinks? Links + { + get { return BackingStore?.Get("links"); } + set { BackingStore?.Set("links", value); } + } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public PrimaryTodoItemResponseDocument() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.PrimaryTodoItemResponseDocument CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.PrimaryTodoItemResponseDocument(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "data", n => { Data = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInTodoItemResponse.CreateFromDiscriminatorValue); } }, + { "included", n => { Included = n.GetCollectionOfObjectValues(global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceInResponse.CreateFromDiscriminatorValue)?.AsList(); } }, + { "links", n => { Links = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceTopLevelLinks.CreateFromDiscriminatorValue); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteObjectValue("data", Data); + writer.WriteCollectionOfObjectValues("included", Included); + writer.WriteObjectValue("links", Links); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipLinks.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipLinks.cs new file mode 100644 index 0000000000..1dfc44ee8d --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipLinks.cs @@ -0,0 +1,79 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class RelationshipLinks : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The related property + public string? Related + { + get { return BackingStore?.Get("related"); } + set { BackingStore?.Set("related", value); } + } + + /// The self property + public string? Self + { + get { return BackingStore?.Get("self"); } + set { BackingStore?.Set("self", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public RelationshipLinks() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipLinks CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipLinks(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "related", n => { Related = n.GetStringValue(); } }, + { "self", n => { Self = n.GetStringValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteStringValue("related", Related); + writer.WriteStringValue("self", Self); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInCreatePersonRequest.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInCreatePersonRequest.cs new file mode 100644 index 0000000000..a7c1dc9803 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInCreatePersonRequest.cs @@ -0,0 +1,68 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class RelationshipsInCreatePersonRequest : global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInCreateRequest, IParsable + #pragma warning restore CS1591 + { + /// The assignedTodoItems property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInRequest? AssignedTodoItems + { + get { return BackingStore?.Get("assignedTodoItems"); } + set { BackingStore?.Set("assignedTodoItems", value); } + } + + /// The ownedTodoItems property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInRequest? OwnedTodoItems + { + get { return BackingStore?.Get("ownedTodoItems"); } + set { BackingStore?.Set("ownedTodoItems", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInCreatePersonRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInCreatePersonRequest(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "assignedTodoItems", n => { AssignedTodoItems = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInRequest.CreateFromDiscriminatorValue); } }, + { "ownedTodoItems", n => { OwnedTodoItems = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInRequest.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteObjectValue("assignedTodoItems", AssignedTodoItems); + writer.WriteObjectValue("ownedTodoItems", OwnedTodoItems); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInCreateRequest.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInCreateRequest.cs new file mode 100644 index 0000000000..c9c8d2247d --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInCreateRequest.cs @@ -0,0 +1,77 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class RelationshipsInCreateRequest : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The openapiDiscriminator property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceType? OpenapiDiscriminator + { + get { return BackingStore?.Get("openapi:discriminator"); } + set { BackingStore?.Set("openapi:discriminator", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public RelationshipsInCreateRequest() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInCreateRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + var mappingValue = parseNode.GetChildNode("openapi:discriminator")?.GetStringValue(); + return mappingValue switch + { + "people" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInCreatePersonRequest(), + "tags" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInCreateTagRequest(), + "todoItems" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInCreateTodoItemRequest(), + _ => new global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInCreateRequest(), + }; + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "openapi:discriminator", n => { OpenapiDiscriminator = n.GetEnumValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteEnumValue("openapi:discriminator", OpenapiDiscriminator); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInCreateTagRequest.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInCreateTagRequest.cs new file mode 100644 index 0000000000..6faaa2cb56 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInCreateTagRequest.cs @@ -0,0 +1,59 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class RelationshipsInCreateTagRequest : global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInCreateRequest, IParsable + #pragma warning restore CS1591 + { + /// The todoItems property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInRequest? TodoItems + { + get { return BackingStore?.Get("todoItems"); } + set { BackingStore?.Set("todoItems", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInCreateTagRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInCreateTagRequest(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "todoItems", n => { TodoItems = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInRequest.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteObjectValue("todoItems", TodoItems); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInCreateTodoItemRequest.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInCreateTodoItemRequest.cs new file mode 100644 index 0000000000..0a8e32873b --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInCreateTodoItemRequest.cs @@ -0,0 +1,77 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class RelationshipsInCreateTodoItemRequest : global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInCreateRequest, IParsable + #pragma warning restore CS1591 + { + /// The assignee property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.NullableToOnePersonInRequest? Assignee + { + get { return BackingStore?.Get("assignee"); } + set { BackingStore?.Set("assignee", value); } + } + + /// The owner property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ToOnePersonInRequest? Owner + { + get { return BackingStore?.Get("owner"); } + set { BackingStore?.Set("owner", value); } + } + + /// The tags property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTagInRequest? Tags + { + get { return BackingStore?.Get("tags"); } + set { BackingStore?.Set("tags", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInCreateTodoItemRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInCreateTodoItemRequest(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "assignee", n => { Assignee = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.NullableToOnePersonInRequest.CreateFromDiscriminatorValue); } }, + { "owner", n => { Owner = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToOnePersonInRequest.CreateFromDiscriminatorValue); } }, + { "tags", n => { Tags = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTagInRequest.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteObjectValue("assignee", Assignee); + writer.WriteObjectValue("owner", Owner); + writer.WriteObjectValue("tags", Tags); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInPersonResponse.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInPersonResponse.cs new file mode 100644 index 0000000000..700c9b301a --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInPersonResponse.cs @@ -0,0 +1,68 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class RelationshipsInPersonResponse : global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInResponse, IParsable + #pragma warning restore CS1591 + { + /// The assignedTodoItems property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInResponse? AssignedTodoItems + { + get { return BackingStore?.Get("assignedTodoItems"); } + set { BackingStore?.Set("assignedTodoItems", value); } + } + + /// The ownedTodoItems property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInResponse? OwnedTodoItems + { + get { return BackingStore?.Get("ownedTodoItems"); } + set { BackingStore?.Set("ownedTodoItems", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInPersonResponse CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInPersonResponse(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "assignedTodoItems", n => { AssignedTodoItems = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInResponse.CreateFromDiscriminatorValue); } }, + { "ownedTodoItems", n => { OwnedTodoItems = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInResponse.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteObjectValue("assignedTodoItems", AssignedTodoItems); + writer.WriteObjectValue("ownedTodoItems", OwnedTodoItems); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInResponse.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInResponse.cs new file mode 100644 index 0000000000..037808aa1f --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInResponse.cs @@ -0,0 +1,77 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class RelationshipsInResponse : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The openapiDiscriminator property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceType? OpenapiDiscriminator + { + get { return BackingStore?.Get("openapi:discriminator"); } + set { BackingStore?.Set("openapi:discriminator", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public RelationshipsInResponse() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInResponse CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + var mappingValue = parseNode.GetChildNode("openapi:discriminator")?.GetStringValue(); + return mappingValue switch + { + "people" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInPersonResponse(), + "tags" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInTagResponse(), + "todoItems" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInTodoItemResponse(), + _ => new global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInResponse(), + }; + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "openapi:discriminator", n => { OpenapiDiscriminator = n.GetEnumValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteEnumValue("openapi:discriminator", OpenapiDiscriminator); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInTagResponse.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInTagResponse.cs new file mode 100644 index 0000000000..eb854e08d8 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInTagResponse.cs @@ -0,0 +1,59 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class RelationshipsInTagResponse : global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInResponse, IParsable + #pragma warning restore CS1591 + { + /// The todoItems property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInResponse? TodoItems + { + get { return BackingStore?.Get("todoItems"); } + set { BackingStore?.Set("todoItems", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInTagResponse CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInTagResponse(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "todoItems", n => { TodoItems = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInResponse.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteObjectValue("todoItems", TodoItems); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInTodoItemResponse.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInTodoItemResponse.cs new file mode 100644 index 0000000000..56e9402ada --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInTodoItemResponse.cs @@ -0,0 +1,77 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class RelationshipsInTodoItemResponse : global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInResponse, IParsable + #pragma warning restore CS1591 + { + /// The assignee property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.NullableToOnePersonInResponse? Assignee + { + get { return BackingStore?.Get("assignee"); } + set { BackingStore?.Set("assignee", value); } + } + + /// The owner property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ToOnePersonInResponse? Owner + { + get { return BackingStore?.Get("owner"); } + set { BackingStore?.Set("owner", value); } + } + + /// The tags property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTagInResponse? Tags + { + get { return BackingStore?.Get("tags"); } + set { BackingStore?.Set("tags", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInTodoItemResponse CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInTodoItemResponse(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "assignee", n => { Assignee = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.NullableToOnePersonInResponse.CreateFromDiscriminatorValue); } }, + { "owner", n => { Owner = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToOnePersonInResponse.CreateFromDiscriminatorValue); } }, + { "tags", n => { Tags = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTagInResponse.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteObjectValue("assignee", Assignee); + writer.WriteObjectValue("owner", Owner); + writer.WriteObjectValue("tags", Tags); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInUpdatePersonRequest.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInUpdatePersonRequest.cs new file mode 100644 index 0000000000..ee9185a310 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInUpdatePersonRequest.cs @@ -0,0 +1,68 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class RelationshipsInUpdatePersonRequest : global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInUpdateRequest, IParsable + #pragma warning restore CS1591 + { + /// The assignedTodoItems property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInRequest? AssignedTodoItems + { + get { return BackingStore?.Get("assignedTodoItems"); } + set { BackingStore?.Set("assignedTodoItems", value); } + } + + /// The ownedTodoItems property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInRequest? OwnedTodoItems + { + get { return BackingStore?.Get("ownedTodoItems"); } + set { BackingStore?.Set("ownedTodoItems", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInUpdatePersonRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInUpdatePersonRequest(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "assignedTodoItems", n => { AssignedTodoItems = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInRequest.CreateFromDiscriminatorValue); } }, + { "ownedTodoItems", n => { OwnedTodoItems = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInRequest.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteObjectValue("assignedTodoItems", AssignedTodoItems); + writer.WriteObjectValue("ownedTodoItems", OwnedTodoItems); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInUpdateRequest.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInUpdateRequest.cs new file mode 100644 index 0000000000..13621d8e84 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInUpdateRequest.cs @@ -0,0 +1,77 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class RelationshipsInUpdateRequest : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The openapiDiscriminator property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceType? OpenapiDiscriminator + { + get { return BackingStore?.Get("openapi:discriminator"); } + set { BackingStore?.Set("openapi:discriminator", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public RelationshipsInUpdateRequest() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInUpdateRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + var mappingValue = parseNode.GetChildNode("openapi:discriminator")?.GetStringValue(); + return mappingValue switch + { + "people" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInUpdatePersonRequest(), + "tags" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInUpdateTagRequest(), + "todoItems" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInUpdateTodoItemRequest(), + _ => new global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInUpdateRequest(), + }; + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "openapi:discriminator", n => { OpenapiDiscriminator = n.GetEnumValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteEnumValue("openapi:discriminator", OpenapiDiscriminator); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInUpdateTagRequest.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInUpdateTagRequest.cs new file mode 100644 index 0000000000..87a99d727d --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInUpdateTagRequest.cs @@ -0,0 +1,59 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class RelationshipsInUpdateTagRequest : global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInUpdateRequest, IParsable + #pragma warning restore CS1591 + { + /// The todoItems property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInRequest? TodoItems + { + get { return BackingStore?.Get("todoItems"); } + set { BackingStore?.Set("todoItems", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInUpdateTagRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInUpdateTagRequest(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "todoItems", n => { TodoItems = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInRequest.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteObjectValue("todoItems", TodoItems); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInUpdateTodoItemRequest.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInUpdateTodoItemRequest.cs new file mode 100644 index 0000000000..0503466973 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RelationshipsInUpdateTodoItemRequest.cs @@ -0,0 +1,77 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class RelationshipsInUpdateTodoItemRequest : global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInUpdateRequest, IParsable + #pragma warning restore CS1591 + { + /// The assignee property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.NullableToOnePersonInRequest? Assignee + { + get { return BackingStore?.Get("assignee"); } + set { BackingStore?.Set("assignee", value); } + } + + /// The owner property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ToOnePersonInRequest? Owner + { + get { return BackingStore?.Get("owner"); } + set { BackingStore?.Set("owner", value); } + } + + /// The tags property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTagInRequest? Tags + { + get { return BackingStore?.Get("tags"); } + set { BackingStore?.Set("tags", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInUpdateTodoItemRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipsInUpdateTodoItemRequest(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "assignee", n => { Assignee = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.NullableToOnePersonInRequest.CreateFromDiscriminatorValue); } }, + { "owner", n => { Owner = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToOnePersonInRequest.CreateFromDiscriminatorValue); } }, + { "tags", n => { Tags = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTagInRequest.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteObjectValue("assignee", Assignee); + writer.WriteObjectValue("owner", Owner); + writer.WriteObjectValue("tags", Tags); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RemoveFromPersonAssignedTodoItemsRelationshipOperation.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RemoveFromPersonAssignedTodoItemsRelationshipOperation.cs new file mode 100644 index 0000000000..a1723493b7 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RemoveFromPersonAssignedTodoItemsRelationshipOperation.cs @@ -0,0 +1,77 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class RemoveFromPersonAssignedTodoItemsRelationshipOperation : global::OpenApiKiotaClientExample.GeneratedCode.Models.AtomicOperation, IParsable + #pragma warning restore CS1591 + { + /// The data property + public List? Data + { + get { return BackingStore?.Get?>("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The op property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.RemoveOperationCode? Op + { + get { return BackingStore?.Get("op"); } + set { BackingStore?.Set("op", value); } + } + + /// The ref property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonAssignedTodoItemsRelationshipIdentifier? Ref + { + get { return BackingStore?.Get("ref"); } + set { BackingStore?.Set("ref", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.RemoveFromPersonAssignedTodoItemsRelationshipOperation CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.RemoveFromPersonAssignedTodoItemsRelationshipOperation(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "data", n => { Data = n.GetCollectionOfObjectValues(global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemIdentifierInRequest.CreateFromDiscriminatorValue)?.AsList(); } }, + { "op", n => { Op = n.GetEnumValue(); } }, + { "ref", n => { Ref = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonAssignedTodoItemsRelationshipIdentifier.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteCollectionOfObjectValues("data", Data); + writer.WriteEnumValue("op", Op); + writer.WriteObjectValue("ref", Ref); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RemoveFromPersonOwnedTodoItemsRelationshipOperation.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RemoveFromPersonOwnedTodoItemsRelationshipOperation.cs new file mode 100644 index 0000000000..d382626143 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RemoveFromPersonOwnedTodoItemsRelationshipOperation.cs @@ -0,0 +1,77 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class RemoveFromPersonOwnedTodoItemsRelationshipOperation : global::OpenApiKiotaClientExample.GeneratedCode.Models.AtomicOperation, IParsable + #pragma warning restore CS1591 + { + /// The data property + public List? Data + { + get { return BackingStore?.Get?>("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The op property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.RemoveOperationCode? Op + { + get { return BackingStore?.Get("op"); } + set { BackingStore?.Set("op", value); } + } + + /// The ref property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonOwnedTodoItemsRelationshipIdentifier? Ref + { + get { return BackingStore?.Get("ref"); } + set { BackingStore?.Set("ref", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.RemoveFromPersonOwnedTodoItemsRelationshipOperation CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.RemoveFromPersonOwnedTodoItemsRelationshipOperation(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "data", n => { Data = n.GetCollectionOfObjectValues(global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemIdentifierInRequest.CreateFromDiscriminatorValue)?.AsList(); } }, + { "op", n => { Op = n.GetEnumValue(); } }, + { "ref", n => { Ref = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonOwnedTodoItemsRelationshipIdentifier.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteCollectionOfObjectValues("data", Data); + writer.WriteEnumValue("op", Op); + writer.WriteObjectValue("ref", Ref); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RemoveFromTagTodoItemsRelationshipOperation.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RemoveFromTagTodoItemsRelationshipOperation.cs new file mode 100644 index 0000000000..dac043b40f --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RemoveFromTagTodoItemsRelationshipOperation.cs @@ -0,0 +1,77 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class RemoveFromTagTodoItemsRelationshipOperation : global::OpenApiKiotaClientExample.GeneratedCode.Models.AtomicOperation, IParsable + #pragma warning restore CS1591 + { + /// The data property + public List? Data + { + get { return BackingStore?.Get?>("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The op property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.RemoveOperationCode? Op + { + get { return BackingStore?.Get("op"); } + set { BackingStore?.Set("op", value); } + } + + /// The ref property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.TagTodoItemsRelationshipIdentifier? Ref + { + get { return BackingStore?.Get("ref"); } + set { BackingStore?.Set("ref", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.RemoveFromTagTodoItemsRelationshipOperation CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.RemoveFromTagTodoItemsRelationshipOperation(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "data", n => { Data = n.GetCollectionOfObjectValues(global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemIdentifierInRequest.CreateFromDiscriminatorValue)?.AsList(); } }, + { "op", n => { Op = n.GetEnumValue(); } }, + { "ref", n => { Ref = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.TagTodoItemsRelationshipIdentifier.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteCollectionOfObjectValues("data", Data); + writer.WriteEnumValue("op", Op); + writer.WriteObjectValue("ref", Ref); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RemoveFromTodoItemTagsRelationshipOperation.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RemoveFromTodoItemTagsRelationshipOperation.cs new file mode 100644 index 0000000000..728838ac24 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RemoveFromTodoItemTagsRelationshipOperation.cs @@ -0,0 +1,77 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class RemoveFromTodoItemTagsRelationshipOperation : global::OpenApiKiotaClientExample.GeneratedCode.Models.AtomicOperation, IParsable + #pragma warning restore CS1591 + { + /// The data property + public List? Data + { + get { return BackingStore?.Get?>("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The op property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.RemoveOperationCode? Op + { + get { return BackingStore?.Get("op"); } + set { BackingStore?.Set("op", value); } + } + + /// The ref property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemTagsRelationshipIdentifier? Ref + { + get { return BackingStore?.Get("ref"); } + set { BackingStore?.Set("ref", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.RemoveFromTodoItemTagsRelationshipOperation CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.RemoveFromTodoItemTagsRelationshipOperation(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "data", n => { Data = n.GetCollectionOfObjectValues(global::OpenApiKiotaClientExample.GeneratedCode.Models.TagIdentifierInRequest.CreateFromDiscriminatorValue)?.AsList(); } }, + { "op", n => { Op = n.GetEnumValue(); } }, + { "ref", n => { Ref = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemTagsRelationshipIdentifier.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteCollectionOfObjectValues("data", Data); + writer.WriteEnumValue("op", Op); + writer.WriteObjectValue("ref", Ref); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RemoveOperationCode.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RemoveOperationCode.cs new file mode 100644 index 0000000000..e49087bf47 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/RemoveOperationCode.cs @@ -0,0 +1,18 @@ +// +#nullable enable +#pragma warning disable CS8625 +using System.Runtime.Serialization; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public enum RemoveOperationCode + #pragma warning restore CS1591 + { + [EnumMember(Value = "remove")] + #pragma warning disable CS1591 + Remove, + #pragma warning restore CS1591 + } +} diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ResourceCollectionTopLevelLinks.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ResourceCollectionTopLevelLinks.cs new file mode 100644 index 0000000000..2eff9f8e0e --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ResourceCollectionTopLevelLinks.cs @@ -0,0 +1,115 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class ResourceCollectionTopLevelLinks : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The describedby property + public string? Describedby + { + get { return BackingStore?.Get("describedby"); } + set { BackingStore?.Set("describedby", value); } + } + + /// The first property + public string? First + { + get { return BackingStore?.Get("first"); } + set { BackingStore?.Set("first", value); } + } + + /// The last property + public string? Last + { + get { return BackingStore?.Get("last"); } + set { BackingStore?.Set("last", value); } + } + + /// The next property + public string? Next + { + get { return BackingStore?.Get("next"); } + set { BackingStore?.Set("next", value); } + } + + /// The prev property + public string? Prev + { + get { return BackingStore?.Get("prev"); } + set { BackingStore?.Set("prev", value); } + } + + /// The self property + public string? Self + { + get { return BackingStore?.Get("self"); } + set { BackingStore?.Set("self", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public ResourceCollectionTopLevelLinks() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceCollectionTopLevelLinks CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceCollectionTopLevelLinks(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "describedby", n => { Describedby = n.GetStringValue(); } }, + { "first", n => { First = n.GetStringValue(); } }, + { "last", n => { Last = n.GetStringValue(); } }, + { "next", n => { Next = n.GetStringValue(); } }, + { "prev", n => { Prev = n.GetStringValue(); } }, + { "self", n => { Self = n.GetStringValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteStringValue("describedby", Describedby); + writer.WriteStringValue("first", First); + writer.WriteStringValue("last", Last); + writer.WriteStringValue("next", Next); + writer.WriteStringValue("prev", Prev); + writer.WriteStringValue("self", Self); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ResourceIdentifierCollectionTopLevelLinks.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ResourceIdentifierCollectionTopLevelLinks.cs new file mode 100644 index 0000000000..5c45ab0eb8 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ResourceIdentifierCollectionTopLevelLinks.cs @@ -0,0 +1,124 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class ResourceIdentifierCollectionTopLevelLinks : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The describedby property + public string? Describedby + { + get { return BackingStore?.Get("describedby"); } + set { BackingStore?.Set("describedby", value); } + } + + /// The first property + public string? First + { + get { return BackingStore?.Get("first"); } + set { BackingStore?.Set("first", value); } + } + + /// The last property + public string? Last + { + get { return BackingStore?.Get("last"); } + set { BackingStore?.Set("last", value); } + } + + /// The next property + public string? Next + { + get { return BackingStore?.Get("next"); } + set { BackingStore?.Set("next", value); } + } + + /// The prev property + public string? Prev + { + get { return BackingStore?.Get("prev"); } + set { BackingStore?.Set("prev", value); } + } + + /// The related property + public string? Related + { + get { return BackingStore?.Get("related"); } + set { BackingStore?.Set("related", value); } + } + + /// The self property + public string? Self + { + get { return BackingStore?.Get("self"); } + set { BackingStore?.Set("self", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public ResourceIdentifierCollectionTopLevelLinks() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceIdentifierCollectionTopLevelLinks CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceIdentifierCollectionTopLevelLinks(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "describedby", n => { Describedby = n.GetStringValue(); } }, + { "first", n => { First = n.GetStringValue(); } }, + { "last", n => { Last = n.GetStringValue(); } }, + { "next", n => { Next = n.GetStringValue(); } }, + { "prev", n => { Prev = n.GetStringValue(); } }, + { "related", n => { Related = n.GetStringValue(); } }, + { "self", n => { Self = n.GetStringValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteStringValue("describedby", Describedby); + writer.WriteStringValue("first", First); + writer.WriteStringValue("last", Last); + writer.WriteStringValue("next", Next); + writer.WriteStringValue("prev", Prev); + writer.WriteStringValue("related", Related); + writer.WriteStringValue("self", Self); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ResourceIdentifierTopLevelLinks.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ResourceIdentifierTopLevelLinks.cs new file mode 100644 index 0000000000..853e4af142 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ResourceIdentifierTopLevelLinks.cs @@ -0,0 +1,88 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class ResourceIdentifierTopLevelLinks : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The describedby property + public string? Describedby + { + get { return BackingStore?.Get("describedby"); } + set { BackingStore?.Set("describedby", value); } + } + + /// The related property + public string? Related + { + get { return BackingStore?.Get("related"); } + set { BackingStore?.Set("related", value); } + } + + /// The self property + public string? Self + { + get { return BackingStore?.Get("self"); } + set { BackingStore?.Set("self", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public ResourceIdentifierTopLevelLinks() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceIdentifierTopLevelLinks CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceIdentifierTopLevelLinks(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "describedby", n => { Describedby = n.GetStringValue(); } }, + { "related", n => { Related = n.GetStringValue(); } }, + { "self", n => { Self = n.GetStringValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteStringValue("describedby", Describedby); + writer.WriteStringValue("related", Related); + writer.WriteStringValue("self", Self); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ResourceInCreateRequest.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ResourceInCreateRequest.cs new file mode 100644 index 0000000000..091cfb0a93 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ResourceInCreateRequest.cs @@ -0,0 +1,86 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class ResourceInCreateRequest : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// The type property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceType? Type + { + get { return BackingStore?.Get("type"); } + set { BackingStore?.Set("type", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public ResourceInCreateRequest() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceInCreateRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + var mappingValue = parseNode.GetChildNode("type")?.GetStringValue(); + return mappingValue switch + { + "people" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInCreatePersonRequest(), + "tags" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInCreateTagRequest(), + "todoItems" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInCreateTodoItemRequest(), + _ => new global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceInCreateRequest(), + }; + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + { "type", n => { Type = n.GetEnumValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteObjectValue("meta", Meta); + writer.WriteEnumValue("type", Type); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ResourceInResponse.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ResourceInResponse.cs new file mode 100644 index 0000000000..b5417c7dd0 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ResourceInResponse.cs @@ -0,0 +1,86 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class ResourceInResponse : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// The type property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceType? Type + { + get { return BackingStore?.Get("type"); } + set { BackingStore?.Set("type", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public ResourceInResponse() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceInResponse CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + var mappingValue = parseNode.GetChildNode("type")?.GetStringValue(); + return mappingValue switch + { + "people" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInPersonResponse(), + "tags" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInTagResponse(), + "todoItems" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInTodoItemResponse(), + _ => new global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceInResponse(), + }; + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + { "type", n => { Type = n.GetEnumValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteObjectValue("meta", Meta); + writer.WriteEnumValue("type", Type); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ResourceInUpdateRequest.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ResourceInUpdateRequest.cs new file mode 100644 index 0000000000..f59c44514e --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ResourceInUpdateRequest.cs @@ -0,0 +1,86 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class ResourceInUpdateRequest : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// The type property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceType? Type + { + get { return BackingStore?.Get("type"); } + set { BackingStore?.Set("type", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public ResourceInUpdateRequest() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceInUpdateRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + var mappingValue = parseNode.GetChildNode("type")?.GetStringValue(); + return mappingValue switch + { + "people" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInUpdatePersonRequest(), + "tags" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInUpdateTagRequest(), + "todoItems" => new global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInUpdateTodoItemRequest(), + _ => new global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceInUpdateRequest(), + }; + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + { "type", n => { Type = n.GetEnumValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteObjectValue("meta", Meta); + writer.WriteEnumValue("type", Type); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ResourceLinks.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ResourceLinks.cs new file mode 100644 index 0000000000..e0b061e6b0 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ResourceLinks.cs @@ -0,0 +1,70 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class ResourceLinks : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The self property + public string? Self + { + get { return BackingStore?.Get("self"); } + set { BackingStore?.Set("self", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public ResourceLinks() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceLinks CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceLinks(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "self", n => { Self = n.GetStringValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteStringValue("self", Self); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ResourceTopLevelLinks.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ResourceTopLevelLinks.cs new file mode 100644 index 0000000000..1f2666f23d --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ResourceTopLevelLinks.cs @@ -0,0 +1,79 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class ResourceTopLevelLinks : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The describedby property + public string? Describedby + { + get { return BackingStore?.Get("describedby"); } + set { BackingStore?.Set("describedby", value); } + } + + /// The self property + public string? Self + { + get { return BackingStore?.Get("self"); } + set { BackingStore?.Set("self", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public ResourceTopLevelLinks() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceTopLevelLinks CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceTopLevelLinks(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "describedby", n => { Describedby = n.GetStringValue(); } }, + { "self", n => { Self = n.GetStringValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteStringValue("describedby", Describedby); + writer.WriteStringValue("self", Self); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ResourceType.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ResourceType.cs new file mode 100644 index 0000000000..0fa83203d6 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ResourceType.cs @@ -0,0 +1,26 @@ +// +#nullable enable +#pragma warning disable CS8625 +using System.Runtime.Serialization; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public enum ResourceType + #pragma warning restore CS1591 + { + [EnumMember(Value = "people")] + #pragma warning disable CS1591 + People, + #pragma warning restore CS1591 + [EnumMember(Value = "tags")] + #pragma warning disable CS1591 + Tags, + #pragma warning restore CS1591 + [EnumMember(Value = "todoItems")] + #pragma warning disable CS1591 + TodoItems, + #pragma warning restore CS1591 + } +} diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/SecondaryPersonResponseDocument.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/SecondaryPersonResponseDocument.cs new file mode 100644 index 0000000000..2ca28ffaba --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/SecondaryPersonResponseDocument.cs @@ -0,0 +1,97 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class SecondaryPersonResponseDocument : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The data property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInPersonResponse? Data + { + get { return BackingStore?.Get("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The included property + public List? Included + { + get { return BackingStore?.Get?>("included"); } + set { BackingStore?.Set("included", value); } + } + + /// The links property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceTopLevelLinks? Links + { + get { return BackingStore?.Get("links"); } + set { BackingStore?.Set("links", value); } + } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public SecondaryPersonResponseDocument() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.SecondaryPersonResponseDocument CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.SecondaryPersonResponseDocument(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "data", n => { Data = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInPersonResponse.CreateFromDiscriminatorValue); } }, + { "included", n => { Included = n.GetCollectionOfObjectValues(global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceInResponse.CreateFromDiscriminatorValue)?.AsList(); } }, + { "links", n => { Links = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceTopLevelLinks.CreateFromDiscriminatorValue); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteObjectValue("data", Data); + writer.WriteCollectionOfObjectValues("included", Included); + writer.WriteObjectValue("links", Links); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TagCollectionResponseDocument.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TagCollectionResponseDocument.cs new file mode 100644 index 0000000000..8a8212126b --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TagCollectionResponseDocument.cs @@ -0,0 +1,97 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class TagCollectionResponseDocument : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The data property + public List? Data + { + get { return BackingStore?.Get?>("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The included property + public List? Included + { + get { return BackingStore?.Get?>("included"); } + set { BackingStore?.Set("included", value); } + } + + /// The links property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceCollectionTopLevelLinks? Links + { + get { return BackingStore?.Get("links"); } + set { BackingStore?.Set("links", value); } + } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public TagCollectionResponseDocument() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.TagCollectionResponseDocument CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.TagCollectionResponseDocument(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "data", n => { Data = n.GetCollectionOfObjectValues(global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInTagResponse.CreateFromDiscriminatorValue)?.AsList(); } }, + { "included", n => { Included = n.GetCollectionOfObjectValues(global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceInResponse.CreateFromDiscriminatorValue)?.AsList(); } }, + { "links", n => { Links = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceCollectionTopLevelLinks.CreateFromDiscriminatorValue); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteCollectionOfObjectValues("data", Data); + writer.WriteCollectionOfObjectValues("included", Included); + writer.WriteObjectValue("links", Links); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TagIdentifierCollectionResponseDocument.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TagIdentifierCollectionResponseDocument.cs new file mode 100644 index 0000000000..30ef21fd5a --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TagIdentifierCollectionResponseDocument.cs @@ -0,0 +1,88 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class TagIdentifierCollectionResponseDocument : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The data property + public List? Data + { + get { return BackingStore?.Get?>("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The links property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceIdentifierCollectionTopLevelLinks? Links + { + get { return BackingStore?.Get("links"); } + set { BackingStore?.Set("links", value); } + } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public TagIdentifierCollectionResponseDocument() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.TagIdentifierCollectionResponseDocument CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.TagIdentifierCollectionResponseDocument(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "data", n => { Data = n.GetCollectionOfObjectValues(global::OpenApiKiotaClientExample.GeneratedCode.Models.TagIdentifierInResponse.CreateFromDiscriminatorValue)?.AsList(); } }, + { "links", n => { Links = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceIdentifierCollectionTopLevelLinks.CreateFromDiscriminatorValue); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteCollectionOfObjectValues("data", Data); + writer.WriteObjectValue("links", Links); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TagIdentifierInRequest.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TagIdentifierInRequest.cs new file mode 100644 index 0000000000..b1fa1ae9ea --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TagIdentifierInRequest.cs @@ -0,0 +1,68 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class TagIdentifierInRequest : global::OpenApiKiotaClientExample.GeneratedCode.Models.IdentifierInRequest, IParsable + #pragma warning restore CS1591 + { + /// The id property + public string? Id + { + get { return BackingStore?.Get("id"); } + set { BackingStore?.Set("id", value); } + } + + /// The lid property + public string? Lid + { + get { return BackingStore?.Get("lid"); } + set { BackingStore?.Set("lid", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.TagIdentifierInRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.TagIdentifierInRequest(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "id", n => { Id = n.GetStringValue(); } }, + { "lid", n => { Lid = n.GetStringValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteStringValue("id", Id); + writer.WriteStringValue("lid", Lid); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TagIdentifierInResponse.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TagIdentifierInResponse.cs new file mode 100644 index 0000000000..6da97abc35 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TagIdentifierInResponse.cs @@ -0,0 +1,88 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class TagIdentifierInResponse : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The id property + public string? Id + { + get { return BackingStore?.Get("id"); } + set { BackingStore?.Set("id", value); } + } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// The type property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.TagResourceType? Type + { + get { return BackingStore?.Get("type"); } + set { BackingStore?.Set("type", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public TagIdentifierInResponse() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.TagIdentifierInResponse CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.TagIdentifierInResponse(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "id", n => { Id = n.GetStringValue(); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + { "type", n => { Type = n.GetEnumValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteStringValue("id", Id); + writer.WriteObjectValue("meta", Meta); + writer.WriteEnumValue("type", Type); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TagResourceType.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TagResourceType.cs new file mode 100644 index 0000000000..5cc9335c56 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TagResourceType.cs @@ -0,0 +1,18 @@ +// +#nullable enable +#pragma warning disable CS8625 +using System.Runtime.Serialization; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public enum TagResourceType + #pragma warning restore CS1591 + { + [EnumMember(Value = "tags")] + #pragma warning disable CS1591 + Tags, + #pragma warning restore CS1591 + } +} diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TagTodoItemsRelationshipIdentifier.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TagTodoItemsRelationshipIdentifier.cs new file mode 100644 index 0000000000..1575de9edd --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TagTodoItemsRelationshipIdentifier.cs @@ -0,0 +1,97 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class TagTodoItemsRelationshipIdentifier : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The id property + public string? Id + { + get { return BackingStore?.Get("id"); } + set { BackingStore?.Set("id", value); } + } + + /// The lid property + public string? Lid + { + get { return BackingStore?.Get("lid"); } + set { BackingStore?.Set("lid", value); } + } + + /// The relationship property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.TagTodoItemsRelationshipName? Relationship + { + get { return BackingStore?.Get("relationship"); } + set { BackingStore?.Set("relationship", value); } + } + + /// The type property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.TagResourceType? Type + { + get { return BackingStore?.Get("type"); } + set { BackingStore?.Set("type", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public TagTodoItemsRelationshipIdentifier() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.TagTodoItemsRelationshipIdentifier CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.TagTodoItemsRelationshipIdentifier(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "id", n => { Id = n.GetStringValue(); } }, + { "lid", n => { Lid = n.GetStringValue(); } }, + { "relationship", n => { Relationship = n.GetEnumValue(); } }, + { "type", n => { Type = n.GetEnumValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteStringValue("id", Id); + writer.WriteStringValue("lid", Lid); + writer.WriteEnumValue("relationship", Relationship); + writer.WriteEnumValue("type", Type); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TagTodoItemsRelationshipName.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TagTodoItemsRelationshipName.cs new file mode 100644 index 0000000000..688ef7a666 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TagTodoItemsRelationshipName.cs @@ -0,0 +1,18 @@ +// +#nullable enable +#pragma warning disable CS8625 +using System.Runtime.Serialization; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public enum TagTodoItemsRelationshipName + #pragma warning restore CS1591 + { + [EnumMember(Value = "todoItems")] + #pragma warning disable CS1591 + TodoItems, + #pragma warning restore CS1591 + } +} diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ToManyTagInRequest.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ToManyTagInRequest.cs new file mode 100644 index 0000000000..f4b01be848 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ToManyTagInRequest.cs @@ -0,0 +1,79 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class ToManyTagInRequest : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The data property + public List? Data + { + get { return BackingStore?.Get?>("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public ToManyTagInRequest() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTagInRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTagInRequest(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "data", n => { Data = n.GetCollectionOfObjectValues(global::OpenApiKiotaClientExample.GeneratedCode.Models.TagIdentifierInRequest.CreateFromDiscriminatorValue)?.AsList(); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteCollectionOfObjectValues("data", Data); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ToManyTagInResponse.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ToManyTagInResponse.cs new file mode 100644 index 0000000000..499498c582 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ToManyTagInResponse.cs @@ -0,0 +1,88 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class ToManyTagInResponse : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The data property + public List? Data + { + get { return BackingStore?.Get?>("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The links property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipLinks? Links + { + get { return BackingStore?.Get("links"); } + set { BackingStore?.Set("links", value); } + } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public ToManyTagInResponse() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTagInResponse CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTagInResponse(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "data", n => { Data = n.GetCollectionOfObjectValues(global::OpenApiKiotaClientExample.GeneratedCode.Models.TagIdentifierInResponse.CreateFromDiscriminatorValue)?.AsList(); } }, + { "links", n => { Links = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipLinks.CreateFromDiscriminatorValue); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteCollectionOfObjectValues("data", Data); + writer.WriteObjectValue("links", Links); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ToManyTodoItemInRequest.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ToManyTodoItemInRequest.cs new file mode 100644 index 0000000000..4278fbdcf1 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ToManyTodoItemInRequest.cs @@ -0,0 +1,79 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class ToManyTodoItemInRequest : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The data property + public List? Data + { + get { return BackingStore?.Get?>("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public ToManyTodoItemInRequest() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInRequest(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "data", n => { Data = n.GetCollectionOfObjectValues(global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemIdentifierInRequest.CreateFromDiscriminatorValue)?.AsList(); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteCollectionOfObjectValues("data", Data); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ToManyTodoItemInResponse.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ToManyTodoItemInResponse.cs new file mode 100644 index 0000000000..cdb6ae027d --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ToManyTodoItemInResponse.cs @@ -0,0 +1,88 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class ToManyTodoItemInResponse : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The data property + public List? Data + { + get { return BackingStore?.Get?>("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The links property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipLinks? Links + { + get { return BackingStore?.Get("links"); } + set { BackingStore?.Set("links", value); } + } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public ToManyTodoItemInResponse() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInResponse CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.ToManyTodoItemInResponse(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "data", n => { Data = n.GetCollectionOfObjectValues(global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemIdentifierInResponse.CreateFromDiscriminatorValue)?.AsList(); } }, + { "links", n => { Links = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipLinks.CreateFromDiscriminatorValue); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteCollectionOfObjectValues("data", Data); + writer.WriteObjectValue("links", Links); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ToOnePersonInRequest.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ToOnePersonInRequest.cs new file mode 100644 index 0000000000..054d054a47 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ToOnePersonInRequest.cs @@ -0,0 +1,79 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class ToOnePersonInRequest : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The data property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonIdentifierInRequest? Data + { + get { return BackingStore?.Get("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public ToOnePersonInRequest() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.ToOnePersonInRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.ToOnePersonInRequest(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "data", n => { Data = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonIdentifierInRequest.CreateFromDiscriminatorValue); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteObjectValue("data", Data); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ToOnePersonInResponse.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ToOnePersonInResponse.cs new file mode 100644 index 0000000000..7223b715cb --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/ToOnePersonInResponse.cs @@ -0,0 +1,88 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class ToOnePersonInResponse : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The data property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonIdentifierInResponse? Data + { + get { return BackingStore?.Get("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The links property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipLinks? Links + { + get { return BackingStore?.Get("links"); } + set { BackingStore?.Set("links", value); } + } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public ToOnePersonInResponse() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.ToOnePersonInResponse CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.ToOnePersonInResponse(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "data", n => { Data = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonIdentifierInResponse.CreateFromDiscriminatorValue); } }, + { "links", n => { Links = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.RelationshipLinks.CreateFromDiscriminatorValue); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteObjectValue("data", Data); + writer.WriteObjectValue("links", Links); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemAssigneeRelationshipIdentifier.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemAssigneeRelationshipIdentifier.cs new file mode 100644 index 0000000000..b3776ea784 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemAssigneeRelationshipIdentifier.cs @@ -0,0 +1,97 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class TodoItemAssigneeRelationshipIdentifier : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The id property + public string? Id + { + get { return BackingStore?.Get("id"); } + set { BackingStore?.Set("id", value); } + } + + /// The lid property + public string? Lid + { + get { return BackingStore?.Get("lid"); } + set { BackingStore?.Set("lid", value); } + } + + /// The relationship property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemAssigneeRelationshipName? Relationship + { + get { return BackingStore?.Get("relationship"); } + set { BackingStore?.Set("relationship", value); } + } + + /// The type property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemResourceType? Type + { + get { return BackingStore?.Get("type"); } + set { BackingStore?.Set("type", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public TodoItemAssigneeRelationshipIdentifier() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemAssigneeRelationshipIdentifier CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemAssigneeRelationshipIdentifier(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "id", n => { Id = n.GetStringValue(); } }, + { "lid", n => { Lid = n.GetStringValue(); } }, + { "relationship", n => { Relationship = n.GetEnumValue(); } }, + { "type", n => { Type = n.GetEnumValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteStringValue("id", Id); + writer.WriteStringValue("lid", Lid); + writer.WriteEnumValue("relationship", Relationship); + writer.WriteEnumValue("type", Type); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemAssigneeRelationshipName.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemAssigneeRelationshipName.cs new file mode 100644 index 0000000000..ab067713d3 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemAssigneeRelationshipName.cs @@ -0,0 +1,18 @@ +// +#nullable enable +#pragma warning disable CS8625 +using System.Runtime.Serialization; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public enum TodoItemAssigneeRelationshipName + #pragma warning restore CS1591 + { + [EnumMember(Value = "assignee")] + #pragma warning disable CS1591 + Assignee, + #pragma warning restore CS1591 + } +} diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemCollectionResponseDocument.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemCollectionResponseDocument.cs new file mode 100644 index 0000000000..39924b4e0c --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemCollectionResponseDocument.cs @@ -0,0 +1,97 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class TodoItemCollectionResponseDocument : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The data property + public List? Data + { + get { return BackingStore?.Get?>("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The included property + public List? Included + { + get { return BackingStore?.Get?>("included"); } + set { BackingStore?.Set("included", value); } + } + + /// The links property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceCollectionTopLevelLinks? Links + { + get { return BackingStore?.Get("links"); } + set { BackingStore?.Set("links", value); } + } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public TodoItemCollectionResponseDocument() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemCollectionResponseDocument CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemCollectionResponseDocument(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "data", n => { Data = n.GetCollectionOfObjectValues(global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInTodoItemResponse.CreateFromDiscriminatorValue)?.AsList(); } }, + { "included", n => { Included = n.GetCollectionOfObjectValues(global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceInResponse.CreateFromDiscriminatorValue)?.AsList(); } }, + { "links", n => { Links = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceCollectionTopLevelLinks.CreateFromDiscriminatorValue); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteCollectionOfObjectValues("data", Data); + writer.WriteCollectionOfObjectValues("included", Included); + writer.WriteObjectValue("links", Links); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemIdentifierCollectionResponseDocument.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemIdentifierCollectionResponseDocument.cs new file mode 100644 index 0000000000..07f5dc0740 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemIdentifierCollectionResponseDocument.cs @@ -0,0 +1,88 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class TodoItemIdentifierCollectionResponseDocument : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The data property + public List? Data + { + get { return BackingStore?.Get?>("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The links property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceIdentifierCollectionTopLevelLinks? Links + { + get { return BackingStore?.Get("links"); } + set { BackingStore?.Set("links", value); } + } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public TodoItemIdentifierCollectionResponseDocument() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemIdentifierCollectionResponseDocument CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemIdentifierCollectionResponseDocument(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "data", n => { Data = n.GetCollectionOfObjectValues(global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemIdentifierInResponse.CreateFromDiscriminatorValue)?.AsList(); } }, + { "links", n => { Links = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.ResourceIdentifierCollectionTopLevelLinks.CreateFromDiscriminatorValue); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteCollectionOfObjectValues("data", Data); + writer.WriteObjectValue("links", Links); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemIdentifierInRequest.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemIdentifierInRequest.cs new file mode 100644 index 0000000000..625c96abfe --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemIdentifierInRequest.cs @@ -0,0 +1,68 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class TodoItemIdentifierInRequest : global::OpenApiKiotaClientExample.GeneratedCode.Models.IdentifierInRequest, IParsable + #pragma warning restore CS1591 + { + /// The id property + public string? Id + { + get { return BackingStore?.Get("id"); } + set { BackingStore?.Set("id", value); } + } + + /// The lid property + public string? Lid + { + get { return BackingStore?.Get("lid"); } + set { BackingStore?.Set("lid", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemIdentifierInRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemIdentifierInRequest(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "id", n => { Id = n.GetStringValue(); } }, + { "lid", n => { Lid = n.GetStringValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteStringValue("id", Id); + writer.WriteStringValue("lid", Lid); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemIdentifierInResponse.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemIdentifierInResponse.cs new file mode 100644 index 0000000000..77e66a4932 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemIdentifierInResponse.cs @@ -0,0 +1,88 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class TodoItemIdentifierInResponse : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The id property + public string? Id + { + get { return BackingStore?.Get("id"); } + set { BackingStore?.Set("id", value); } + } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// The type property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemResourceType? Type + { + get { return BackingStore?.Get("type"); } + set { BackingStore?.Set("type", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public TodoItemIdentifierInResponse() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemIdentifierInResponse CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemIdentifierInResponse(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "id", n => { Id = n.GetStringValue(); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + { "type", n => { Type = n.GetEnumValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteStringValue("id", Id); + writer.WriteObjectValue("meta", Meta); + writer.WriteEnumValue("type", Type); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemOwnerRelationshipIdentifier.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemOwnerRelationshipIdentifier.cs new file mode 100644 index 0000000000..9280347acf --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemOwnerRelationshipIdentifier.cs @@ -0,0 +1,97 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class TodoItemOwnerRelationshipIdentifier : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The id property + public string? Id + { + get { return BackingStore?.Get("id"); } + set { BackingStore?.Set("id", value); } + } + + /// The lid property + public string? Lid + { + get { return BackingStore?.Get("lid"); } + set { BackingStore?.Set("lid", value); } + } + + /// The relationship property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemOwnerRelationshipName? Relationship + { + get { return BackingStore?.Get("relationship"); } + set { BackingStore?.Set("relationship", value); } + } + + /// The type property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemResourceType? Type + { + get { return BackingStore?.Get("type"); } + set { BackingStore?.Set("type", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public TodoItemOwnerRelationshipIdentifier() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemOwnerRelationshipIdentifier CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemOwnerRelationshipIdentifier(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "id", n => { Id = n.GetStringValue(); } }, + { "lid", n => { Lid = n.GetStringValue(); } }, + { "relationship", n => { Relationship = n.GetEnumValue(); } }, + { "type", n => { Type = n.GetEnumValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteStringValue("id", Id); + writer.WriteStringValue("lid", Lid); + writer.WriteEnumValue("relationship", Relationship); + writer.WriteEnumValue("type", Type); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemOwnerRelationshipName.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemOwnerRelationshipName.cs new file mode 100644 index 0000000000..9b93bc5cf2 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemOwnerRelationshipName.cs @@ -0,0 +1,18 @@ +// +#nullable enable +#pragma warning disable CS8625 +using System.Runtime.Serialization; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public enum TodoItemOwnerRelationshipName + #pragma warning restore CS1591 + { + [EnumMember(Value = "owner")] + #pragma warning disable CS1591 + Owner, + #pragma warning restore CS1591 + } +} diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemPriority.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemPriority.cs new file mode 100644 index 0000000000..1736350c36 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemPriority.cs @@ -0,0 +1,26 @@ +// +#nullable enable +#pragma warning disable CS8625 +using System.Runtime.Serialization; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public enum TodoItemPriority + #pragma warning restore CS1591 + { + [EnumMember(Value = "High")] + #pragma warning disable CS1591 + High, + #pragma warning restore CS1591 + [EnumMember(Value = "Medium")] + #pragma warning disable CS1591 + Medium, + #pragma warning restore CS1591 + [EnumMember(Value = "Low")] + #pragma warning disable CS1591 + Low, + #pragma warning restore CS1591 + } +} diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemResourceType.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemResourceType.cs new file mode 100644 index 0000000000..3f6583ffa8 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemResourceType.cs @@ -0,0 +1,18 @@ +// +#nullable enable +#pragma warning disable CS8625 +using System.Runtime.Serialization; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public enum TodoItemResourceType + #pragma warning restore CS1591 + { + [EnumMember(Value = "todoItems")] + #pragma warning disable CS1591 + TodoItems, + #pragma warning restore CS1591 + } +} diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemTagsRelationshipIdentifier.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemTagsRelationshipIdentifier.cs new file mode 100644 index 0000000000..8f60910bf9 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemTagsRelationshipIdentifier.cs @@ -0,0 +1,97 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class TodoItemTagsRelationshipIdentifier : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The id property + public string? Id + { + get { return BackingStore?.Get("id"); } + set { BackingStore?.Set("id", value); } + } + + /// The lid property + public string? Lid + { + get { return BackingStore?.Get("lid"); } + set { BackingStore?.Set("lid", value); } + } + + /// The relationship property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemTagsRelationshipName? Relationship + { + get { return BackingStore?.Get("relationship"); } + set { BackingStore?.Set("relationship", value); } + } + + /// The type property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemResourceType? Type + { + get { return BackingStore?.Get("type"); } + set { BackingStore?.Set("type", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public TodoItemTagsRelationshipIdentifier() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemTagsRelationshipIdentifier CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemTagsRelationshipIdentifier(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "id", n => { Id = n.GetStringValue(); } }, + { "lid", n => { Lid = n.GetStringValue(); } }, + { "relationship", n => { Relationship = n.GetEnumValue(); } }, + { "type", n => { Type = n.GetEnumValue(); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteStringValue("id", Id); + writer.WriteStringValue("lid", Lid); + writer.WriteEnumValue("relationship", Relationship); + writer.WriteEnumValue("type", Type); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemTagsRelationshipName.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemTagsRelationshipName.cs new file mode 100644 index 0000000000..e5c0a6aecd --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/TodoItemTagsRelationshipName.cs @@ -0,0 +1,18 @@ +// +#nullable enable +#pragma warning disable CS8625 +using System.Runtime.Serialization; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public enum TodoItemTagsRelationshipName + #pragma warning restore CS1591 + { + [EnumMember(Value = "tags")] + #pragma warning disable CS1591 + Tags, + #pragma warning restore CS1591 + } +} diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdateOperationCode.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdateOperationCode.cs new file mode 100644 index 0000000000..615d734065 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdateOperationCode.cs @@ -0,0 +1,18 @@ +// +#nullable enable +#pragma warning disable CS8625 +using System.Runtime.Serialization; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public enum UpdateOperationCode + #pragma warning restore CS1591 + { + [EnumMember(Value = "update")] + #pragma warning disable CS1591 + Update, + #pragma warning restore CS1591 + } +} diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdatePersonAssignedTodoItemsRelationshipOperation.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdatePersonAssignedTodoItemsRelationshipOperation.cs new file mode 100644 index 0000000000..4e49dae8ab --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdatePersonAssignedTodoItemsRelationshipOperation.cs @@ -0,0 +1,77 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class UpdatePersonAssignedTodoItemsRelationshipOperation : global::OpenApiKiotaClientExample.GeneratedCode.Models.AtomicOperation, IParsable + #pragma warning restore CS1591 + { + /// The data property + public List? Data + { + get { return BackingStore?.Get?>("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The op property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateOperationCode? Op + { + get { return BackingStore?.Get("op"); } + set { BackingStore?.Set("op", value); } + } + + /// The ref property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonAssignedTodoItemsRelationshipIdentifier? Ref + { + get { return BackingStore?.Get("ref"); } + set { BackingStore?.Set("ref", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdatePersonAssignedTodoItemsRelationshipOperation CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdatePersonAssignedTodoItemsRelationshipOperation(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "data", n => { Data = n.GetCollectionOfObjectValues(global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemIdentifierInRequest.CreateFromDiscriminatorValue)?.AsList(); } }, + { "op", n => { Op = n.GetEnumValue(); } }, + { "ref", n => { Ref = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonAssignedTodoItemsRelationshipIdentifier.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteCollectionOfObjectValues("data", Data); + writer.WriteEnumValue("op", Op); + writer.WriteObjectValue("ref", Ref); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdatePersonOperation.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdatePersonOperation.cs new file mode 100644 index 0000000000..d9f9ebc327 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdatePersonOperation.cs @@ -0,0 +1,77 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class UpdatePersonOperation : global::OpenApiKiotaClientExample.GeneratedCode.Models.AtomicOperation, IParsable + #pragma warning restore CS1591 + { + /// The data property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInUpdatePersonRequest? Data + { + get { return BackingStore?.Get("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The op property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateOperationCode? Op + { + get { return BackingStore?.Get("op"); } + set { BackingStore?.Set("op", value); } + } + + /// The ref property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonIdentifierInRequest? Ref + { + get { return BackingStore?.Get("ref"); } + set { BackingStore?.Set("ref", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdatePersonOperation CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdatePersonOperation(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "data", n => { Data = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInUpdatePersonRequest.CreateFromDiscriminatorValue); } }, + { "op", n => { Op = n.GetEnumValue(); } }, + { "ref", n => { Ref = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonIdentifierInRequest.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteObjectValue("data", Data); + writer.WriteEnumValue("op", Op); + writer.WriteObjectValue("ref", Ref); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdatePersonOwnedTodoItemsRelationshipOperation.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdatePersonOwnedTodoItemsRelationshipOperation.cs new file mode 100644 index 0000000000..d3897e3e91 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdatePersonOwnedTodoItemsRelationshipOperation.cs @@ -0,0 +1,77 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class UpdatePersonOwnedTodoItemsRelationshipOperation : global::OpenApiKiotaClientExample.GeneratedCode.Models.AtomicOperation, IParsable + #pragma warning restore CS1591 + { + /// The data property + public List? Data + { + get { return BackingStore?.Get?>("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The op property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateOperationCode? Op + { + get { return BackingStore?.Get("op"); } + set { BackingStore?.Set("op", value); } + } + + /// The ref property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonOwnedTodoItemsRelationshipIdentifier? Ref + { + get { return BackingStore?.Get("ref"); } + set { BackingStore?.Set("ref", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdatePersonOwnedTodoItemsRelationshipOperation CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdatePersonOwnedTodoItemsRelationshipOperation(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "data", n => { Data = n.GetCollectionOfObjectValues(global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemIdentifierInRequest.CreateFromDiscriminatorValue)?.AsList(); } }, + { "op", n => { Op = n.GetEnumValue(); } }, + { "ref", n => { Ref = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonOwnedTodoItemsRelationshipIdentifier.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteCollectionOfObjectValues("data", Data); + writer.WriteEnumValue("op", Op); + writer.WriteObjectValue("ref", Ref); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdatePersonRequestDocument.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdatePersonRequestDocument.cs new file mode 100644 index 0000000000..6a66c970c4 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdatePersonRequestDocument.cs @@ -0,0 +1,79 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class UpdatePersonRequestDocument : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The data property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInUpdatePersonRequest? Data + { + get { return BackingStore?.Get("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public UpdatePersonRequestDocument() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdatePersonRequestDocument CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdatePersonRequestDocument(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "data", n => { Data = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInUpdatePersonRequest.CreateFromDiscriminatorValue); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteObjectValue("data", Data); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdateTagOperation.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdateTagOperation.cs new file mode 100644 index 0000000000..a6f95262a7 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdateTagOperation.cs @@ -0,0 +1,77 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class UpdateTagOperation : global::OpenApiKiotaClientExample.GeneratedCode.Models.AtomicOperation, IParsable + #pragma warning restore CS1591 + { + /// The data property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInUpdateTagRequest? Data + { + get { return BackingStore?.Get("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The op property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateOperationCode? Op + { + get { return BackingStore?.Get("op"); } + set { BackingStore?.Set("op", value); } + } + + /// The ref property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.TagIdentifierInRequest? Ref + { + get { return BackingStore?.Get("ref"); } + set { BackingStore?.Set("ref", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateTagOperation CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateTagOperation(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "data", n => { Data = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInUpdateTagRequest.CreateFromDiscriminatorValue); } }, + { "op", n => { Op = n.GetEnumValue(); } }, + { "ref", n => { Ref = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.TagIdentifierInRequest.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteObjectValue("data", Data); + writer.WriteEnumValue("op", Op); + writer.WriteObjectValue("ref", Ref); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdateTagRequestDocument.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdateTagRequestDocument.cs new file mode 100644 index 0000000000..7bd46becea --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdateTagRequestDocument.cs @@ -0,0 +1,79 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class UpdateTagRequestDocument : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The data property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInUpdateTagRequest? Data + { + get { return BackingStore?.Get("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public UpdateTagRequestDocument() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateTagRequestDocument CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateTagRequestDocument(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "data", n => { Data = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInUpdateTagRequest.CreateFromDiscriminatorValue); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteObjectValue("data", Data); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdateTagTodoItemsRelationshipOperation.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdateTagTodoItemsRelationshipOperation.cs new file mode 100644 index 0000000000..d566ab853a --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdateTagTodoItemsRelationshipOperation.cs @@ -0,0 +1,77 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class UpdateTagTodoItemsRelationshipOperation : global::OpenApiKiotaClientExample.GeneratedCode.Models.AtomicOperation, IParsable + #pragma warning restore CS1591 + { + /// The data property + public List? Data + { + get { return BackingStore?.Get?>("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The op property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateOperationCode? Op + { + get { return BackingStore?.Get("op"); } + set { BackingStore?.Set("op", value); } + } + + /// The ref property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.TagTodoItemsRelationshipIdentifier? Ref + { + get { return BackingStore?.Get("ref"); } + set { BackingStore?.Set("ref", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateTagTodoItemsRelationshipOperation CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateTagTodoItemsRelationshipOperation(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "data", n => { Data = n.GetCollectionOfObjectValues(global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemIdentifierInRequest.CreateFromDiscriminatorValue)?.AsList(); } }, + { "op", n => { Op = n.GetEnumValue(); } }, + { "ref", n => { Ref = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.TagTodoItemsRelationshipIdentifier.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteCollectionOfObjectValues("data", Data); + writer.WriteEnumValue("op", Op); + writer.WriteObjectValue("ref", Ref); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdateTodoItemAssigneeRelationshipOperation.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdateTodoItemAssigneeRelationshipOperation.cs new file mode 100644 index 0000000000..c97917c75b --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdateTodoItemAssigneeRelationshipOperation.cs @@ -0,0 +1,77 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class UpdateTodoItemAssigneeRelationshipOperation : global::OpenApiKiotaClientExample.GeneratedCode.Models.AtomicOperation, IParsable + #pragma warning restore CS1591 + { + /// The data property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonIdentifierInRequest? Data + { + get { return BackingStore?.Get("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The op property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateOperationCode? Op + { + get { return BackingStore?.Get("op"); } + set { BackingStore?.Set("op", value); } + } + + /// The ref property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemAssigneeRelationshipIdentifier? Ref + { + get { return BackingStore?.Get("ref"); } + set { BackingStore?.Set("ref", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateTodoItemAssigneeRelationshipOperation CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateTodoItemAssigneeRelationshipOperation(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "data", n => { Data = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonIdentifierInRequest.CreateFromDiscriminatorValue); } }, + { "op", n => { Op = n.GetEnumValue(); } }, + { "ref", n => { Ref = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemAssigneeRelationshipIdentifier.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteObjectValue("data", Data); + writer.WriteEnumValue("op", Op); + writer.WriteObjectValue("ref", Ref); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdateTodoItemOperation.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdateTodoItemOperation.cs new file mode 100644 index 0000000000..6e98779249 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdateTodoItemOperation.cs @@ -0,0 +1,77 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class UpdateTodoItemOperation : global::OpenApiKiotaClientExample.GeneratedCode.Models.AtomicOperation, IParsable + #pragma warning restore CS1591 + { + /// The data property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInUpdateTodoItemRequest? Data + { + get { return BackingStore?.Get("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The op property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateOperationCode? Op + { + get { return BackingStore?.Get("op"); } + set { BackingStore?.Set("op", value); } + } + + /// The ref property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemIdentifierInRequest? Ref + { + get { return BackingStore?.Get("ref"); } + set { BackingStore?.Set("ref", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateTodoItemOperation CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateTodoItemOperation(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "data", n => { Data = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInUpdateTodoItemRequest.CreateFromDiscriminatorValue); } }, + { "op", n => { Op = n.GetEnumValue(); } }, + { "ref", n => { Ref = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemIdentifierInRequest.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteObjectValue("data", Data); + writer.WriteEnumValue("op", Op); + writer.WriteObjectValue("ref", Ref); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdateTodoItemOwnerRelationshipOperation.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdateTodoItemOwnerRelationshipOperation.cs new file mode 100644 index 0000000000..15a12c97f3 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdateTodoItemOwnerRelationshipOperation.cs @@ -0,0 +1,77 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class UpdateTodoItemOwnerRelationshipOperation : global::OpenApiKiotaClientExample.GeneratedCode.Models.AtomicOperation, IParsable + #pragma warning restore CS1591 + { + /// The data property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonIdentifierInRequest? Data + { + get { return BackingStore?.Get("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The op property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateOperationCode? Op + { + get { return BackingStore?.Get("op"); } + set { BackingStore?.Set("op", value); } + } + + /// The ref property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemOwnerRelationshipIdentifier? Ref + { + get { return BackingStore?.Get("ref"); } + set { BackingStore?.Set("ref", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateTodoItemOwnerRelationshipOperation CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateTodoItemOwnerRelationshipOperation(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "data", n => { Data = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.PersonIdentifierInRequest.CreateFromDiscriminatorValue); } }, + { "op", n => { Op = n.GetEnumValue(); } }, + { "ref", n => { Ref = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemOwnerRelationshipIdentifier.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteObjectValue("data", Data); + writer.WriteEnumValue("op", Op); + writer.WriteObjectValue("ref", Ref); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdateTodoItemRequestDocument.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdateTodoItemRequestDocument.cs new file mode 100644 index 0000000000..1d39ef6392 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdateTodoItemRequestDocument.cs @@ -0,0 +1,79 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions.Store; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class UpdateTodoItemRequestDocument : IBackedModel, IParsable + #pragma warning restore CS1591 + { + /// Stores model information. + public IBackingStore BackingStore { get; private set; } + + /// The data property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInUpdateTodoItemRequest? Data + { + get { return BackingStore?.Get("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The meta property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta? Meta + { + get { return BackingStore?.Get("meta"); } + set { BackingStore?.Set("meta", value); } + } + + /// + /// Instantiates a new and sets the default values. + /// + public UpdateTodoItemRequestDocument() + { + BackingStore = BackingStoreFactorySingleton.Instance.CreateBackingStore(); + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateTodoItemRequestDocument CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateTodoItemRequestDocument(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "data", n => { Data = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.DataInUpdateTodoItemRequest.CreateFromDiscriminatorValue); } }, + { "meta", n => { Meta = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.Meta.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + writer.WriteObjectValue("data", Data); + writer.WriteObjectValue("meta", Meta); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdateTodoItemTagsRelationshipOperation.cs b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdateTodoItemTagsRelationshipOperation.cs new file mode 100644 index 0000000000..68b848e164 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/GeneratedCode/Models/UpdateTodoItemTagsRelationshipOperation.cs @@ -0,0 +1,77 @@ +// +#nullable enable +#pragma warning disable CS8625 +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace OpenApiKiotaClientExample.GeneratedCode.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class UpdateTodoItemTagsRelationshipOperation : global::OpenApiKiotaClientExample.GeneratedCode.Models.AtomicOperation, IParsable + #pragma warning restore CS1591 + { + /// The data property + public List? Data + { + get { return BackingStore?.Get?>("data"); } + set { BackingStore?.Set("data", value); } + } + + /// The op property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateOperationCode? Op + { + get { return BackingStore?.Get("op"); } + set { BackingStore?.Set("op", value); } + } + + /// The ref property + public global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemTagsRelationshipIdentifier? Ref + { + get { return BackingStore?.Get("ref"); } + set { BackingStore?.Set("ref", value); } + } + + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static new global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateTodoItemTagsRelationshipOperation CreateFromDiscriminatorValue(IParseNode parseNode) + { + _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode)); + return new global::OpenApiKiotaClientExample.GeneratedCode.Models.UpdateTodoItemTagsRelationshipOperation(); + } + + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public override IDictionary> GetFieldDeserializers() + { + return new Dictionary>(base.GetFieldDeserializers()) + { + { "data", n => { Data = n.GetCollectionOfObjectValues(global::OpenApiKiotaClientExample.GeneratedCode.Models.TagIdentifierInRequest.CreateFromDiscriminatorValue)?.AsList(); } }, + { "op", n => { Op = n.GetEnumValue(); } }, + { "ref", n => { Ref = n.GetObjectValue(global::OpenApiKiotaClientExample.GeneratedCode.Models.TodoItemTagsRelationshipIdentifier.CreateFromDiscriminatorValue); } }, + }; + } + + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public override void Serialize(ISerializationWriter writer) + { + _ = writer ?? throw new ArgumentNullException(nameof(writer)); + base.Serialize(writer); + writer.WriteCollectionOfObjectValues("data", Data); + writer.WriteEnumValue("op", Op); + writer.WriteObjectValue("ref", Ref); + } + } +} +#pragma warning restore CS0618 diff --git a/src/Examples/OpenApiKiotaClientExample/OpenApiKiotaClientExample.csproj b/src/Examples/OpenApiKiotaClientExample/OpenApiKiotaClientExample.csproj new file mode 100644 index 0000000000..8f65b2b688 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/OpenApiKiotaClientExample.csproj @@ -0,0 +1,28 @@ + + + net9.0 + + + + + + + + + + + + + + + + + + + $(MSBuildProjectName).GeneratedCode + ExampleApiClient + ./GeneratedCode + $(JsonApiExtraArguments) + + + diff --git a/src/Examples/OpenApiKiotaClientExample/PeopleMessageFormatter.cs b/src/Examples/OpenApiKiotaClientExample/PeopleMessageFormatter.cs new file mode 100644 index 0000000000..db22ae6231 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/PeopleMessageFormatter.cs @@ -0,0 +1,65 @@ +using System.Text; +using JetBrains.Annotations; +using OpenApiKiotaClientExample.GeneratedCode.Models; + +namespace OpenApiKiotaClientExample; + +/// +/// Prints the specified people, their assigned todo-items, and its tags. +/// +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +internal sealed class PeopleMessageFormatter +{ + public static void PrintPeople(PersonCollectionResponseDocument? peopleResponse) + { + string message = WritePeople(peopleResponse); + Console.WriteLine(message); + } + + private static string WritePeople(PersonCollectionResponseDocument? peopleResponse) + { + if (peopleResponse == null) + { + return "No response body was returned."; + } + + var builder = new StringBuilder(); + builder.AppendLine($"Found {peopleResponse.Data!.Count} people:"); + + foreach (DataInPersonResponse person in peopleResponse.Data) + { + WritePerson(person, peopleResponse.Included ?? [], builder); + } + + return builder.ToString(); + } + + private static void WritePerson(DataInPersonResponse person, List includes, StringBuilder builder) + { + List assignedTodoItems = person.Relationships?.AssignedTodoItems?.Data ?? []; + + builder.AppendLine($" Person {person.Id}: {person.Attributes?.DisplayName} with {assignedTodoItems.Count} assigned todo-items:"); + WriteRelatedTodoItems(assignedTodoItems, includes, builder); + } + + private static void WriteRelatedTodoItems(List todoItemIdentifiers, List includes, StringBuilder builder) + { + foreach (TodoItemIdentifierInResponse todoItemIdentifier in todoItemIdentifiers) + { + DataInTodoItemResponse includedTodoItem = includes.OfType().Single(include => include.Id == todoItemIdentifier.Id); + List tags = includedTodoItem.Relationships?.Tags?.Data ?? []; + + builder.AppendLine($" TodoItem {includedTodoItem.Id}: {includedTodoItem.Attributes?.Description} with {tags.Count} tags:"); + WriteRelatedTags(tags, includes, builder); + } + } + + private static void WriteRelatedTags(List tagIdentifiers, List includes, StringBuilder builder) + { + foreach (TagIdentifierInResponse tagIdentifier in tagIdentifiers) + { + DataInTagResponse includedTag = includes.OfType().Single(include => include.Id == tagIdentifier.Id); + builder.AppendLine($" Tag {includedTag.Id}: {includedTag.Attributes?.Name}"); + } + } +} diff --git a/src/Examples/OpenApiKiotaClientExample/Program.cs b/src/Examples/OpenApiKiotaClientExample/Program.cs new file mode 100644 index 0000000000..2716335282 --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/Program.cs @@ -0,0 +1,37 @@ +using JsonApiDotNetCore.OpenApi.Client.Kiota; +using Microsoft.Kiota.Abstractions.Authentication; +using Microsoft.Kiota.Http.HttpClientLibrary; +using OpenApiKiotaClientExample; +using OpenApiKiotaClientExample.GeneratedCode; + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); +builder.Services.AddLogging(options => options.ClearProviders()); +builder.Services.AddHostedService(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// @formatter:wrap_chained_method_calls chop_always +builder.Services.AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(_ => + { + IList defaultHandlers = KiotaClientFactory.CreateDefaultHandlers(); + HttpMessageHandler defaultHttpMessageHandler = KiotaClientFactory.GetDefaultHttpMessageHandler(); + + // Or, if your generated client is long-lived, respond to DNS updates using: + // HttpMessageHandler defaultHttpMessageHandler = new SocketsHttpHandler(); + + return KiotaClientFactory.ChainHandlersCollectionAndGetFirstLink(defaultHttpMessageHandler, defaultHandlers.ToArray())!; + }) + .AddHttpMessageHandler() + .AddHttpMessageHandler() + .AddTypedClient((httpClient, serviceProvider) => + { + var authenticationProvider = serviceProvider.GetRequiredService(); + var requestAdapter = new HttpClientRequestAdapter(authenticationProvider, httpClient: httpClient); + return new ExampleApiClient(requestAdapter); + }); +// @formatter:wrap_chained_method_calls restore + +IHost host = builder.Build(); +await host.RunAsync(); diff --git a/src/Examples/OpenApiKiotaClientExample/Properties/launchSettings.json b/src/Examples/OpenApiKiotaClientExample/Properties/launchSettings.json new file mode 100644 index 0000000000..142f412e8a --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "Kestrel": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Examples/OpenApiKiotaClientExample/Worker.cs b/src/Examples/OpenApiKiotaClientExample/Worker.cs new file mode 100644 index 0000000000..56c6cd6b0d --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/Worker.cs @@ -0,0 +1,212 @@ +using System.Net; +using JsonApiDotNetCore.OpenApi.Client.Kiota; +using Microsoft.Kiota.Abstractions; +using Microsoft.Kiota.Http.HttpClientLibrary.Middleware.Options; +using OpenApiKiotaClientExample.GeneratedCode; +using OpenApiKiotaClientExample.GeneratedCode.Models; + +namespace OpenApiKiotaClientExample; + +public sealed class Worker(ExampleApiClient apiClient, IHostApplicationLifetime hostApplicationLifetime, SetQueryStringHttpMessageHandler queryStringHandler) + : BackgroundService +{ + private readonly ExampleApiClient _apiClient = apiClient; + private readonly IHostApplicationLifetime _hostApplicationLifetime = hostApplicationLifetime; + private readonly SetQueryStringHttpMessageHandler _queryStringHandler = queryStringHandler; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + using (_queryStringHandler.CreateScope(new Dictionary + { + // Workaround for https://github.com/microsoft/kiota/issues/3800. + ["filter"] = "has(assignedTodoItems)", + ["sort"] = "-lastName", + ["page[size]"] = "5", + ["include"] = "assignedTodoItems.tags" + })) + { + (PersonCollectionResponseDocument? getResponse, string? eTag) = await GetPeopleAsync(_apiClient, null, stoppingToken); + PeopleMessageFormatter.PrintPeople(getResponse); + + (PersonCollectionResponseDocument? getResponseAgain, _) = await GetPeopleAsync(_apiClient, eTag, stoppingToken); + PeopleMessageFormatter.PrintPeople(getResponseAgain); + } + + await UpdatePersonAsync(stoppingToken); + + await SendOperationsRequestAsync(stoppingToken); + + await _apiClient.Api.People["999999"].GetAsync(cancellationToken: stoppingToken); + } + catch (ErrorResponseDocument exception) + { + Console.WriteLine($"JSON:API ERROR: {exception.Errors!.First().Detail}"); + } + catch (HttpRequestException exception) + { + Console.WriteLine($"ERROR: {exception.Message}"); + } + + _hostApplicationLifetime.StopApplication(); + } + + private static async Task<(PersonCollectionResponseDocument? response, string? eTag)> GetPeopleAsync(ExampleApiClient apiClient, string? ifNoneMatch, + CancellationToken cancellationToken) + { + try + { + var headerInspector = new HeadersInspectionHandlerOption + { + InspectResponseHeaders = true + }; + + PersonCollectionResponseDocument? response = await apiClient.Api.People.GetAsync(configuration => + { + if (!string.IsNullOrEmpty(ifNoneMatch)) + { + configuration.Headers.Add("If-None-Match", ifNoneMatch); + } + + configuration.Options.Add(headerInspector); + }, cancellationToken); + + string eTag = headerInspector.ResponseHeaders["ETag"].Single(); + + return (response, eTag); + } + // Workaround for https://github.com/microsoft/kiota/issues/4190. + catch (ApiException exception) when (exception.ResponseStatusCode == (int)HttpStatusCode.NotModified) + { + return (null, null); + } + } + + private async Task UpdatePersonAsync(CancellationToken cancellationToken) + { + var updatePersonRequest = new UpdatePersonRequestDocument + { + Data = new DataInUpdatePersonRequest + { + Type = ResourceType.People, + Id = "1", + Attributes = new AttributesInUpdatePersonRequest + { + // The --backing-store switch enables to send null and default values. + FirstName = null, + LastName = "Doe" + } + } + }; + + await _apiClient.Api.People[updatePersonRequest.Data.Id].PatchAsync(updatePersonRequest, cancellationToken: cancellationToken); + } + + private async Task SendOperationsRequestAsync(CancellationToken cancellationToken) + { + var operationsRequest = new OperationsRequestDocument + { + AtomicOperations = + [ + new CreateTagOperation + { + Op = AddOperationCode.Add, + Data = new DataInCreateTagRequest + { + Type = ResourceType.Tags, + Lid = "new-tag", + Attributes = new AttributesInCreateTagRequest + { + Name = "Housekeeping" + } + } + }, + new CreatePersonOperation + { + Op = AddOperationCode.Add, + Data = new DataInCreatePersonRequest + { + Type = ResourceType.People, + Lid = "new-person", + Attributes = new AttributesInCreatePersonRequest + { + FirstName = "Cinderella", + LastName = "Tremaine" + } + } + }, + new UpdatePersonOperation + { + Op = UpdateOperationCode.Update, + Data = new DataInUpdatePersonRequest + { + Type = ResourceType.People, + Lid = "new-person", + Attributes = new AttributesInUpdatePersonRequest + { + // The --backing-store switch enables to send null and default values. + FirstName = null + } + } + }, + new CreateTodoItemOperation + { + Op = AddOperationCode.Add, + Data = new DataInCreateTodoItemRequest + { + Type = ResourceType.TodoItems, + Lid = "new-todo-item", + Attributes = new AttributesInCreateTodoItemRequest + { + Description = "Put out the garbage", + Priority = TodoItemPriority.Medium + }, + Relationships = new RelationshipsInCreateTodoItemRequest + { + Owner = new ToOnePersonInRequest + { + Data = new PersonIdentifierInRequest + { + Type = ResourceType.People, + Lid = "new-person" + } + }, + Tags = new ToManyTagInRequest + { + Data = + [ + new TagIdentifierInRequest + { + Type = ResourceType.Tags, + Lid = "new-tag" + } + ] + } + } + } + }, + new UpdateTodoItemAssigneeRelationshipOperation + { + Op = UpdateOperationCode.Update, + Ref = new TodoItemAssigneeRelationshipIdentifier + { + Type = TodoItemResourceType.TodoItems, + Lid = "new-todo-item", + Relationship = TodoItemAssigneeRelationshipName.Assignee + }, + Data = new PersonIdentifierInRequest + { + Type = ResourceType.People, + Lid = "new-person" + } + } + ] + }; + + OperationsResponseDocument? operationsResponse = await _apiClient.Api.Operations.PostAsync(operationsRequest, cancellationToken: cancellationToken); + + var newTodoItem = (DataInTodoItemResponse)operationsResponse!.AtomicResults!.ElementAt(3).Data!; + Console.WriteLine($"Created todo-item with ID {newTodoItem.Id}: {newTodoItem.Attributes!.Description}."); + } +} diff --git a/src/Examples/OpenApiKiotaClientExample/appsettings.json b/src/Examples/OpenApiKiotaClientExample/appsettings.json new file mode 100644 index 0000000000..2b94cdb46d --- /dev/null +++ b/src/Examples/OpenApiKiotaClientExample/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "System.Net.Http.HttpClient": "Information" + } + } +} diff --git a/src/Examples/OpenApiNSwagClientExample/.editorconfig b/src/Examples/OpenApiNSwagClientExample/.editorconfig new file mode 100644 index 0000000000..e2ec1cac44 --- /dev/null +++ b/src/Examples/OpenApiNSwagClientExample/.editorconfig @@ -0,0 +1,3 @@ +# Workaround for incorrect nullability in NSwag generated clients. +[*Client.cs] +dotnet_diagnostic.CS8765.severity = none diff --git a/src/Examples/OpenApiNSwagClientExample/ColoredConsoleLogHttpMessageHandler.cs b/src/Examples/OpenApiNSwagClientExample/ColoredConsoleLogHttpMessageHandler.cs new file mode 100644 index 0000000000..d999a7d9bf --- /dev/null +++ b/src/Examples/OpenApiNSwagClientExample/ColoredConsoleLogHttpMessageHandler.cs @@ -0,0 +1,67 @@ +using JetBrains.Annotations; + +namespace OpenApiNSwagClientExample; + +/// +/// Writes incoming and outgoing HTTP messages to the console. +/// +internal sealed class ColoredConsoleLogHttpMessageHandler : DelegatingHandler +{ + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { +#if DEBUG + await LogRequestAsync(request, cancellationToken); +#endif + + HttpResponseMessage response = await base.SendAsync(request, cancellationToken); + +#if DEBUG + await LogResponseAsync(response, cancellationToken); +#endif + + return response; + } + + [UsedImplicitly] + private static async Task LogRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + using var _ = new ConsoleColorScope(ConsoleColor.Green); + + Console.WriteLine($"--> {request}"); + string? requestBody = request.Content != null ? await request.Content.ReadAsStringAsync(cancellationToken) : null; + + if (!string.IsNullOrEmpty(requestBody)) + { + Console.WriteLine(); + Console.WriteLine(requestBody); + } + } + + [UsedImplicitly] + private static async Task LogResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + using var _ = new ConsoleColorScope(ConsoleColor.Cyan); + + Console.WriteLine($"<-- {response}"); + string responseBody = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!string.IsNullOrEmpty(responseBody)) + { + Console.WriteLine(); + Console.WriteLine(responseBody); + } + } + + private sealed class ConsoleColorScope : IDisposable + { + public ConsoleColorScope(ConsoleColor foregroundColor) + { + Console.ForegroundColor = foregroundColor; + } + + public void Dispose() + { + Console.ResetColor(); + } + } +} diff --git a/src/Examples/OpenApiNSwagClientExample/OpenApiNSwagClientExample.csproj b/src/Examples/OpenApiNSwagClientExample/OpenApiNSwagClientExample.csproj new file mode 100644 index 0000000000..c30833a39a --- /dev/null +++ b/src/Examples/OpenApiNSwagClientExample/OpenApiNSwagClientExample.csproj @@ -0,0 +1,31 @@ + + + + net9.0 + + + + + + + + + + + + + + + + + + + + ExampleApi + $(MSBuildProjectName) + %(Name)Client + %(ClassName).cs + true + + + diff --git a/src/Examples/OpenApiNSwagClientExample/PeopleMessageFormatter.cs b/src/Examples/OpenApiNSwagClientExample/PeopleMessageFormatter.cs new file mode 100644 index 0000000000..0a870d7682 --- /dev/null +++ b/src/Examples/OpenApiNSwagClientExample/PeopleMessageFormatter.cs @@ -0,0 +1,66 @@ +using System.Text; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.Client.NSwag; + +namespace OpenApiNSwagClientExample; + +/// +/// Prints the specified people, their assigned todo-items, and its tags. +/// +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +internal sealed class PeopleMessageFormatter +{ + public static void PrintPeople(ApiResponse peopleResponse) + { + string message = WritePeople(peopleResponse); + Console.WriteLine(message); + } + + private static string WritePeople(ApiResponse peopleResponse) + { + if (peopleResponse.Result == null) + { + return $"Status code {peopleResponse.StatusCode} was returned without a response body."; + } + + var builder = new StringBuilder(); + builder.AppendLine($"Found {peopleResponse.Result.Data.Count} people:"); + + foreach (DataInPersonResponse person in peopleResponse.Result.Data) + { + WritePerson(person, peopleResponse.Result.Included ?? [], builder); + } + + return builder.ToString(); + } + + private static void WritePerson(DataInPersonResponse person, ICollection includes, StringBuilder builder) + { + ICollection assignedTodoItems = person.Relationships?.AssignedTodoItems?.Data ?? []; + + builder.AppendLine($" Person {person.Id}: {person.Attributes?.DisplayName} with {assignedTodoItems.Count} assigned todo-items:"); + WriteRelatedTodoItems(assignedTodoItems, includes, builder); + } + + private static void WriteRelatedTodoItems(IEnumerable todoItemIdentifiers, ICollection includes, + StringBuilder builder) + { + foreach (TodoItemIdentifierInResponse todoItemIdentifier in todoItemIdentifiers) + { + DataInTodoItemResponse includedTodoItem = includes.OfType().Single(include => include.Id == todoItemIdentifier.Id); + ICollection tags = includedTodoItem.Relationships?.Tags?.Data ?? []; + + builder.AppendLine($" TodoItem {includedTodoItem.Id}: {includedTodoItem.Attributes?.Description} with {tags.Count} tags:"); + WriteRelatedTags(tags, includes, builder); + } + } + + private static void WriteRelatedTags(IEnumerable tagIdentifiers, ICollection includes, StringBuilder builder) + { + foreach (TagIdentifierInResponse tagIdentifier in tagIdentifiers) + { + DataInTagResponse includedTag = includes.OfType().Single(include => include.Id == tagIdentifier.Id); + builder.AppendLine($" Tag {includedTag.Id}: {includedTag.Attributes?.Name}"); + } + } +} diff --git a/src/Examples/OpenApiNSwagClientExample/Program.cs b/src/Examples/OpenApiNSwagClientExample/Program.cs new file mode 100644 index 0000000000..67d81a7ad1 --- /dev/null +++ b/src/Examples/OpenApiNSwagClientExample/Program.cs @@ -0,0 +1,10 @@ +using OpenApiNSwagClientExample; + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); +builder.Services.AddLogging(options => options.ClearProviders()); +builder.Services.AddHostedService(); +builder.Services.AddSingleton(); +builder.Services.AddHttpClient().AddHttpMessageHandler(); + +IHost host = builder.Build(); +await host.RunAsync(); diff --git a/src/Examples/OpenApiNSwagClientExample/Properties/launchSettings.json b/src/Examples/OpenApiNSwagClientExample/Properties/launchSettings.json new file mode 100644 index 0000000000..142f412e8a --- /dev/null +++ b/src/Examples/OpenApiNSwagClientExample/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "Kestrel": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Examples/OpenApiNSwagClientExample/Worker.cs b/src/Examples/OpenApiNSwagClientExample/Worker.cs new file mode 100644 index 0000000000..7727c88a15 --- /dev/null +++ b/src/Examples/OpenApiNSwagClientExample/Worker.cs @@ -0,0 +1,171 @@ +using JsonApiDotNetCore.OpenApi.Client.NSwag; + +namespace OpenApiNSwagClientExample; + +public sealed class Worker(ExampleApiClient apiClient, IHostApplicationLifetime hostApplicationLifetime) : BackgroundService +{ + private readonly ExampleApiClient _apiClient = apiClient; + private readonly IHostApplicationLifetime _hostApplicationLifetime = hostApplicationLifetime; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + var queryString = new Dictionary + { + ["filter"] = "has(assignedTodoItems)", + ["sort"] = "-lastName", + ["page[size]"] = "5", + ["include"] = "assignedTodoItems.tags" + }; + + ApiResponse getResponse = await GetPeopleAsync(_apiClient, queryString, null, stoppingToken); + PeopleMessageFormatter.PrintPeople(getResponse); + + string eTag = getResponse.Headers["ETag"].Single(); + ApiResponse getResponseAgain = await GetPeopleAsync(_apiClient, queryString, eTag, stoppingToken); + PeopleMessageFormatter.PrintPeople(getResponseAgain); + + await UpdatePersonAsync(stoppingToken); + + await SendOperationsRequestAsync(stoppingToken); + + await _apiClient.GetPersonAsync("999999", null, null, stoppingToken); + } + catch (ApiException exception) + { + Console.WriteLine($"JSON:API ERROR: {exception.Result.Errors.First().Detail}"); + } + catch (HttpRequestException exception) + { + Console.WriteLine($"ERROR: {exception.Message}"); + } + + _hostApplicationLifetime.StopApplication(); + } + + private static Task> GetPeopleAsync(ExampleApiClient apiClient, IDictionary queryString, + string? ifNoneMatch, CancellationToken cancellationToken) + { + return ApiResponse.TranslateAsync(async () => await apiClient.GetPersonCollectionAsync(queryString, ifNoneMatch, cancellationToken)); + } + + private async Task UpdatePersonAsync(CancellationToken cancellationToken) + { + var updatePersonRequest = new UpdatePersonRequestDocument + { + Data = new DataInUpdatePersonRequest + { + Id = "1", + // Using TrackChangesFor to send "firstName: null" instead of omitting it. + Attributes = new TrackChangesFor(_apiClient) + { + Initializer = + { + FirstName = null, + LastName = "Doe" + } + }.Initializer + } + }; + + await ApiResponse.TranslateAsync(async () => + await _apiClient.PatchPersonAsync(updatePersonRequest.Data.Id, updatePersonRequest, cancellationToken: cancellationToken)); + } + + private async Task SendOperationsRequestAsync(CancellationToken cancellationToken) + { + var operationsRequest = new OperationsRequestDocument + { + Atomic_operations = + [ + new CreateTagOperation + { + Data = new DataInCreateTagRequest + { + Lid = "new-tag", + Attributes = new AttributesInCreateTagRequest + { + Name = "Housekeeping" + } + } + }, + new CreatePersonOperation + { + Data = new DataInCreatePersonRequest + { + Lid = "new-person", + Attributes = new AttributesInCreatePersonRequest + { + FirstName = "Cinderella", + LastName = "Tremaine" + } + } + }, + new UpdatePersonOperation + { + Data = new DataInUpdatePersonRequest + { + Lid = "new-person", + // Using TrackChangesFor to send "firstName: null" instead of omitting it. + Attributes = new TrackChangesFor(_apiClient) + { + Initializer = + { + FirstName = null + } + }.Initializer + } + }, + new CreateTodoItemOperation + { + Data = new DataInCreateTodoItemRequest + { + Lid = "new-todo-item", + Attributes = new AttributesInCreateTodoItemRequest + { + Description = "Put out the garbage", + Priority = TodoItemPriority.Medium + }, + Relationships = new RelationshipsInCreateTodoItemRequest + { + Owner = new ToOnePersonInRequest + { + Data = new PersonIdentifierInRequest + { + Lid = "new-person" + } + }, + Tags = new ToManyTagInRequest + { + Data = + [ + new TagIdentifierInRequest + { + Lid = "new-tag" + } + ] + } + } + } + }, + new UpdateTodoItemAssigneeRelationshipOperation + { + Ref = new TodoItemAssigneeRelationshipIdentifier + { + Lid = "new-todo-item" + }, + Data = new PersonIdentifierInRequest + { + Lid = "new-person" + } + } + ] + }; + + ApiResponse operationsResponse = await _apiClient.PostOperationsAsync(operationsRequest, cancellationToken); + + var newTodoItem = (DataInTodoItemResponse)operationsResponse.Result.Atomic_results.ElementAt(3).Data!; + Console.WriteLine($"Created todo-item with ID {newTodoItem.Id}: {newTodoItem.Attributes!.Description}."); + } +} diff --git a/src/Examples/OpenApiNSwagClientExample/appsettings.json b/src/Examples/OpenApiNSwagClientExample/appsettings.json new file mode 100644 index 0000000000..2b94cdb46d --- /dev/null +++ b/src/Examples/OpenApiNSwagClientExample/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.Hosting.Lifetime": "Information", + "System.Net.Http.HttpClient": "Information" + } + } +} diff --git a/src/Examples/ReportsExample/Program.cs b/src/Examples/ReportsExample/Program.cs index 16abac381f..7f89ad9301 100644 --- a/src/Examples/ReportsExample/Program.cs +++ b/src/Examples/ReportsExample/Program.cs @@ -4,7 +4,17 @@ // Add services to the container. -builder.Services.AddJsonApi(options => options.Namespace = "api", discovery => discovery.AddCurrentAssembly()); +builder.Services.AddJsonApi(options => +{ + options.Namespace = "api"; + options.UseRelativeLinks = true; + +#if DEBUG + options.IncludeExceptionStackTraceInErrors = true; + options.IncludeRequestBodyInErrors = true; + options.SerializerOptions.WriteIndented = true; +#endif +}, discovery => discovery.AddCurrentAssembly()); WebApplication app = builder.Build(); @@ -14,4 +24,4 @@ app.UseJsonApi(); app.MapControllers(); -app.Run(); +await app.RunAsync(); diff --git a/src/Examples/ReportsExample/Properties/launchSettings.json b/src/Examples/ReportsExample/Properties/launchSettings.json index 7add074ef2..83e6baea4c 100644 --- a/src/Examples/ReportsExample/Properties/launchSettings.json +++ b/src/Examples/ReportsExample/Properties/launchSettings.json @@ -1,5 +1,5 @@ { - "$schema": "http://json.schemastore.org/launchsettings.json", + "$schema": "https://json.schemastore.org/launchsettings.json", "iisSettings": { "windowsAuthentication": false, "anonymousAuthentication": true, diff --git a/src/Examples/ReportsExample/ReportsExample.csproj b/src/Examples/ReportsExample/ReportsExample.csproj index b243e99ec2..6ade1386be 100644 --- a/src/Examples/ReportsExample/ReportsExample.csproj +++ b/src/Examples/ReportsExample/ReportsExample.csproj @@ -1,16 +1,13 @@ - $(TargetFrameworkName) + net9.0;net8.0 + + - - - - - diff --git a/src/Examples/ReportsExample/Services/ReportService.cs b/src/Examples/ReportsExample/Services/ReportService.cs index 61a97ecde5..62bb7c9554 100644 --- a/src/Examples/ReportsExample/Services/ReportService.cs +++ b/src/Examples/ReportsExample/Services/ReportService.cs @@ -7,27 +7,17 @@ namespace ReportsExample.Services; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public class ReportService : IGetAllService { - private readonly ILogger _logger; - - public ReportService(ILoggerFactory loggerFactory) - { - _logger = loggerFactory.CreateLogger(); - } - public Task> GetAsync(CancellationToken cancellationToken) { - _logger.LogInformation("GetAsync"); - - IReadOnlyCollection reports = GetReports(); - + IReadOnlyCollection reports = GetReports().AsReadOnly(); return Task.FromResult(reports); } - private IReadOnlyCollection GetReports() + private List GetReports() { - return new List - { - new() + return + [ + new Report { Id = 1, Title = "Status Report", @@ -37,6 +27,6 @@ private IReadOnlyCollection GetReports() HoursSpent = 24 } } - }; + ]; } } diff --git a/src/Examples/ReportsExample/appsettings.json b/src/Examples/ReportsExample/appsettings.json index 270cabc088..1e325ebe92 100644 --- a/src/Examples/ReportsExample/appsettings.json +++ b/src/Examples/ReportsExample/appsettings.json @@ -2,8 +2,9 @@ "Logging": { "LogLevel": { "Default": "Warning", + // Include server startup and incoming requests. "Microsoft.Hosting.Lifetime": "Information", - "Microsoft.EntityFrameworkCore.Database.Command": "Information" + "Microsoft.AspNetCore.Hosting.Diagnostics": "Information" } }, "AllowedHosts": "*" diff --git a/src/JsonApiDotNetCore.Annotations/ArgumentGuard.cs b/src/JsonApiDotNetCore.Annotations/ArgumentGuard.cs index be336e56a0..1eea1c3841 100644 --- a/src/JsonApiDotNetCore.Annotations/ArgumentGuard.cs +++ b/src/JsonApiDotNetCore.Annotations/ArgumentGuard.cs @@ -1,52 +1,22 @@ +using System.Runtime.CompilerServices; using JetBrains.Annotations; using SysNotNull = System.Diagnostics.CodeAnalysis.NotNullAttribute; #pragma warning disable AV1008 // Class should not be static +#pragma warning disable format namespace JsonApiDotNetCore; internal static class ArgumentGuard { [AssertionMethod] - public static void NotNull([NoEnumeration] [SysNotNull] T? value, [InvokerParameterName] string name) - where T : class + public static void NotNullNorEmpty([SysNotNull] IEnumerable? value, [CallerArgumentExpression(nameof(value))] string? parameterName = null) { - if (value is null) - { - throw new ArgumentNullException(name); - } - } - - [AssertionMethod] - public static void NotNullNorEmpty([SysNotNull] IEnumerable? value, [InvokerParameterName] string name) - { - NotNull(value, name); + ArgumentNullException.ThrowIfNull(value, parameterName); if (!value.Any()) { - throw new ArgumentException($"Must have one or more {name}.", name); - } - } - - [AssertionMethod] - public static void NotNullNorEmpty([SysNotNull] IEnumerable? value, [InvokerParameterName] string name, string collectionName) - { - NotNull(value, name); - - if (!value.Any()) - { - throw new ArgumentException($"Must have one or more {collectionName}.", name); - } - } - - [AssertionMethod] - public static void NotNullNorEmpty([SysNotNull] string? value, [InvokerParameterName] string name) - { - NotNull(value, name); - - if (value == string.Empty) - { - throw new ArgumentException("String cannot be null or empty.", name); + throw new ArgumentException("Collection cannot be null or empty.", parameterName); } } } diff --git a/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs b/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs index a308607c3b..683e34764b 100644 --- a/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs +++ b/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs @@ -5,15 +5,21 @@ namespace JsonApiDotNetCore; internal sealed class CollectionConverter { - private static readonly ISet HashSetCompatibleCollectionTypes = new HashSet - { + private static readonly HashSet HashSetCompatibleCollectionTypes = + [ typeof(HashSet<>), typeof(ISet<>), typeof(IReadOnlySet<>), typeof(ICollection<>), typeof(IReadOnlyCollection<>), typeof(IEnumerable<>) - }; + ]; + + public static CollectionConverter Instance { get; } = new(); + + private CollectionConverter() + { + } /// /// Creates a collection instance based on the specified collection type and copies the specified elements into it. @@ -22,12 +28,16 @@ internal sealed class CollectionConverter /// Source to copy from. /// /// - /// Target collection type, for example: typeof(List{Article}) or typeof(ISet{Person}). + /// Target collection type, for example: ) + /// ]]> or ) + /// ]]>. /// public IEnumerable CopyToTypedCollection(IEnumerable source, Type collectionType) { - ArgumentGuard.NotNull(source, nameof(source)); - ArgumentGuard.NotNull(collectionType, nameof(collectionType)); + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(collectionType); Type concreteCollectionType = ToConcreteCollectionType(collectionType); dynamic concreteCollectionInstance = Activator.CreateInstance(concreteCollectionType)!; @@ -41,11 +51,16 @@ public IEnumerable CopyToTypedCollection(IEnumerable source, Type collectionType } /// - /// Returns a compatible collection type that can be instantiated, for example IList{Article} -> List{Article} or ISet{Article} -> HashSet{Article} + /// Returns a compatible collection type that can be instantiated, for example: -> List
+ /// ]]> or + /// -> HashSet
+ /// ]]>. ///
private Type ToConcreteCollectionType(Type collectionType) { - if (collectionType.IsInterface && collectionType.IsGenericType) + if (collectionType is { IsInterface: true, IsGenericType: true }) { Type openCollectionType = collectionType.GetGenericTypeDefinition(); @@ -68,60 +83,58 @@ private Type ToConcreteCollectionType(Type collectionType) ///
public IReadOnlyCollection ExtractResources(object? value) { - if (value is List resourceList) - { - return resourceList; - } - - if (value is HashSet resourceSet) - { - return resourceSet; - } - - if (value is IReadOnlyCollection resourceCollection) + return value switch { - return resourceCollection; - } - - if (value is IEnumerable resources) - { - return resources.ToList(); - } - - if (value is IIdentifiable resource) - { - return resource.AsArray(); - } - - return Array.Empty(); + List resourceList => resourceList.AsReadOnly(), + HashSet resourceSet => resourceSet.AsReadOnly(), + IReadOnlyCollection resourceCollection => resourceCollection, + IEnumerable resources => resources.ToArray().AsReadOnly(), + IIdentifiable resource => [resource], + _ => Array.Empty() + }; } /// - /// Returns the element type if the specified type is a generic collection, for example: IList{string} -> string or IList -> null. + /// Returns the element type if the specified type is a generic collection, for example: -> string + /// ]]> or + /// null + /// ]]>. /// public Type? FindCollectionElementType(Type? type) { if (type != null) { - if (type.IsGenericType && type.GenericTypeArguments.Length == 1) + Type? enumerableClosedType = IsEnumerableClosedType(type) ? type : null; + enumerableClosedType ??= type.GetInterfaces().FirstOrDefault(IsEnumerableClosedType); + + if (enumerableClosedType != null) { - if (type.IsOrImplementsInterface()) - { - return type.GenericTypeArguments[0]; - } + return enumerableClosedType.GenericTypeArguments[0]; } } return null; } + private static bool IsEnumerableClosedType(Type type) + { + bool isClosedType = type is { IsGenericType: true, ContainsGenericParameters: false }; + return isClosedType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>); + } + /// - /// Indicates whether a instance can be assigned to the specified type, for example IList{Article} -> false or ISet{Article} -> - /// true. + /// Indicates whether a instance can be assigned to the specified type, for example: + /// -> false + /// ]]> or -> true + /// ]]>. /// public bool TypeCanContainHashSet(Type collectionType) { - ArgumentGuard.NotNull(collectionType, nameof(collectionType)); + ArgumentNullException.ThrowIfNull(collectionType); if (collectionType.IsGenericType) { 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 515dfe8a63..4c0cd133f9 100644 --- a/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs +++ b/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs @@ -9,8 +9,12 @@ namespace JsonApiDotNetCore.Configuration; [PublicAPI] public sealed class ResourceType { - private readonly Dictionary _fieldsByPublicName = new(); - private readonly Dictionary _fieldsByPropertyName = new(); + private static readonly IReadOnlySet EmptyResourceTypeSet = new HashSet().AsReadOnly(); + private static readonly IReadOnlySet EmptyAttributeSet = new HashSet().AsReadOnly(); + private static readonly IReadOnlySet EmptyRelationshipSet = new HashSet().AsReadOnly(); + + private readonly Dictionary _fieldsByPublicName = []; + private readonly Dictionary _fieldsByPropertyName = []; private readonly Lazy> _lazyAllConcreteDerivedTypes; /// @@ -18,6 +22,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. /// @@ -36,7 +46,7 @@ public sealed class ResourceType /// /// The resource types that directly derive from this one. /// - public IReadOnlySet DirectlyDerivedTypes { get; internal set; } = new HashSet(); + public IReadOnlySet DirectlyDerivedTypes { get; internal set; } = EmptyResourceTypeSet; /// /// Exposed resource attributes and relationships. See https://jsonapi.org/format/#document-resource-object-fields. When using resource inheritance, this @@ -89,22 +99,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, nameof(publicName)); - ArgumentGuard.NotNull(clrType, nameof(clrType)); - ArgumentGuard.NotNull(identityClrType, nameof(identityClrType)); + ArgumentException.ThrowIfNullOrWhiteSpace(publicName); + ArgumentNullException.ThrowIfNull(clrType); + ArgumentNullException.ThrowIfNull(identityClrType); PublicName = publicName; + ClientIdGeneration = clientIdGeneration; ClrType = clrType; IdentityClrType = identityClrType; Attributes = attributes ?? Array.Empty(); @@ -113,7 +125,7 @@ public ResourceType(string publicName, Type clrType, Type identityClrType, IRead TopLevelLinks = topLevelLinks; ResourceLinks = resourceLinks; RelationshipLinks = relationshipLinks; - Fields = Attributes.Cast().Concat(Relationships).ToArray(); + Fields = Attributes.Cast().Concat(Relationships).ToArray().AsReadOnly(); foreach (ResourceFieldAttribute field in Fields) { @@ -126,10 +138,10 @@ public ResourceType(string publicName, Type clrType, Type identityClrType, IRead private IReadOnlySet ResolveAllConcreteDerivedTypes() { - var allConcreteDerivedTypes = new HashSet(); + HashSet allConcreteDerivedTypes = []; AddConcreteDerivedTypes(this, allConcreteDerivedTypes); - return allConcreteDerivedTypes; + return allConcreteDerivedTypes.AsReadOnly(); } private static void AddConcreteDerivedTypes(ResourceType resourceType, ISet allConcreteDerivedTypes) @@ -153,7 +165,7 @@ public AttrAttribute GetAttributeByPublicName(string publicName) public AttrAttribute? FindAttributeByPublicName(string publicName) { - ArgumentGuard.NotNull(publicName, nameof(publicName)); + ArgumentNullException.ThrowIfNull(publicName); return _fieldsByPublicName.TryGetValue(publicName, out ResourceFieldAttribute? field) && field is AttrAttribute attribute ? attribute : null; } @@ -161,13 +173,12 @@ public AttrAttribute GetAttributeByPublicName(string publicName) public AttrAttribute GetAttributeByPropertyName(string propertyName) { AttrAttribute? attribute = FindAttributeByPropertyName(propertyName); - return attribute ?? throw new InvalidOperationException($"Attribute for property '{propertyName}' does not exist on resource type '{ClrType.Name}'."); } public AttrAttribute? FindAttributeByPropertyName(string propertyName) { - ArgumentGuard.NotNull(propertyName, nameof(propertyName)); + ArgumentNullException.ThrowIfNull(propertyName); return _fieldsByPropertyName.TryGetValue(propertyName, out ResourceFieldAttribute? field) && field is AttrAttribute attribute ? attribute : null; } @@ -180,7 +191,7 @@ public RelationshipAttribute GetRelationshipByPublicName(string publicName) public RelationshipAttribute? FindRelationshipByPublicName(string publicName) { - ArgumentGuard.NotNull(publicName, nameof(publicName)); + ArgumentNullException.ThrowIfNull(publicName); return _fieldsByPublicName.TryGetValue(publicName, out ResourceFieldAttribute? field) && field is RelationshipAttribute relationship ? relationship @@ -197,7 +208,7 @@ public RelationshipAttribute GetRelationshipByPropertyName(string propertyName) public RelationshipAttribute? FindRelationshipByPropertyName(string propertyName) { - ArgumentGuard.NotNull(propertyName, nameof(propertyName)); + ArgumentNullException.ThrowIfNull(propertyName); return _fieldsByPropertyName.TryGetValue(propertyName, out ResourceFieldAttribute? field) && field is RelationshipAttribute relationship ? relationship @@ -205,7 +216,7 @@ public RelationshipAttribute GetRelationshipByPropertyName(string propertyName) } /// - /// Returns all directly and indirectly non-abstract resource types that derive from this resource type. + /// Returns all non-abstract resource types that directly or indirectly derive from this resource type. /// public IReadOnlySet GetAllConcreteDerivedTypes() { @@ -217,7 +228,7 @@ public IReadOnlySet GetAllConcreteDerivedTypes() /// public ResourceType GetTypeOrDerived(Type clrType) { - ArgumentGuard.NotNull(clrType, nameof(clrType)); + ArgumentNullException.ThrowIfNull(clrType); ResourceType? derivedType = FindTypeOrDerived(this, clrType); @@ -251,7 +262,20 @@ public ResourceType GetTypeOrDerived(Type clrType) internal IReadOnlySet GetAttributesInTypeOrDerived(string publicName) { - return GetAttributesInTypeOrDerived(this, publicName); + if (IsPartOfTypeHierarchy()) + { + return GetAttributesInTypeOrDerived(this, publicName); + } + + AttrAttribute? attribute = FindAttributeByPublicName(publicName); + + if (attribute == null) + { + return EmptyAttributeSet; + } + + HashSet attributes = [attribute]; + return attributes.AsReadOnly(); } private static IReadOnlySet GetAttributesInTypeOrDerived(ResourceType resourceType, string publicName) @@ -260,12 +284,13 @@ private static IReadOnlySet GetAttributesInTypeOrDerived(Resource if (attribute != null) { - return attribute.AsHashSet(); + HashSet attributes = [attribute]; + return attributes.AsReadOnly(); } // Hiding base members using the 'new' keyword instead of 'override' (effectively breaking inheritance) is currently not supported. // https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/knowing-when-to-use-override-and-new-keywords - HashSet attributesInDerivedTypes = new(); + HashSet attributesInDerivedTypes = []; foreach (AttrAttribute attributeInDerivedType in resourceType.DirectlyDerivedTypes .Select(derivedType => GetAttributesInTypeOrDerived(derivedType, publicName)).SelectMany(attributesInDerivedType => attributesInDerivedType)) @@ -273,12 +298,25 @@ private static IReadOnlySet GetAttributesInTypeOrDerived(Resource attributesInDerivedTypes.Add(attributeInDerivedType); } - return attributesInDerivedTypes; + return attributesInDerivedTypes.AsReadOnly(); } internal IReadOnlySet GetRelationshipsInTypeOrDerived(string publicName) { - return GetRelationshipsInTypeOrDerived(this, publicName); + if (IsPartOfTypeHierarchy()) + { + return GetRelationshipsInTypeOrDerived(this, publicName); + } + + RelationshipAttribute? relationship = FindRelationshipByPublicName(publicName); + + if (relationship == null) + { + return EmptyRelationshipSet; + } + + HashSet relationships = [relationship]; + return relationships.AsReadOnly(); } private static IReadOnlySet GetRelationshipsInTypeOrDerived(ResourceType resourceType, string publicName) @@ -287,12 +325,13 @@ private static IReadOnlySet GetRelationshipsInTypeOrDeriv if (relationship != null) { - return relationship.AsHashSet(); + HashSet relationships = [relationship]; + return relationships.AsReadOnly(); } // Hiding base members using the 'new' keyword instead of 'override' (effectively breaking inheritance) is currently not supported. // https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/knowing-when-to-use-override-and-new-keywords - HashSet relationshipsInDerivedTypes = new(); + HashSet relationshipsInDerivedTypes = []; foreach (RelationshipAttribute relationshipInDerivedType in resourceType.DirectlyDerivedTypes .Select(derivedType => GetRelationshipsInTypeOrDerived(derivedType, publicName)) @@ -301,12 +340,12 @@ private static IReadOnlySet GetRelationshipsInTypeOrDeriv relationshipsInDerivedTypes.Add(relationshipInDerivedType); } - return relationshipsInDerivedTypes; + return relationshipsInDerivedTypes.AsReadOnly(); } internal bool IsPartOfTypeHierarchy() { - return BaseType != null || DirectlyDerivedTypes.Any(); + return BaseType != null || DirectlyDerivedTypes.Count > 0; } public override string ToString() diff --git a/src/JsonApiDotNetCore.Annotations/Controllers/JsonApiEndpoints.cs b/src/JsonApiDotNetCore.Annotations/Controllers/JsonApiEndpoints.cs deleted file mode 100644 index 92ba933fe3..0000000000 --- a/src/JsonApiDotNetCore.Annotations/Controllers/JsonApiEndpoints.cs +++ /dev/null @@ -1,29 +0,0 @@ -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. -[PublicAPI] -[Flags] -public enum JsonApiEndpoints -{ - None = 0, - GetCollection = 1, - GetSingle = 1 << 1, - GetSecondary = 1 << 2, - GetRelationship = 1 << 3, - Post = 1 << 4, - PostRelationship = 1 << 5, - Patch = 1 << 6, - PatchRelationship = 1 << 7, - Delete = 1 << 8, - DeleteRelationship = 1 << 9, - - Query = GetCollection | GetSingle | GetSecondary | GetRelationship, - Command = Post | PostRelationship | Patch | PatchRelationship | Delete | DeleteRelationship, - - All = Query | Command -} diff --git a/src/JsonApiDotNetCore.Annotations/Controllers/JsonApiEndpoints.shared.cs b/src/JsonApiDotNetCore.Annotations/Controllers/JsonApiEndpoints.shared.cs new file mode 100644 index 0000000000..a6d11e6093 --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Controllers/JsonApiEndpoints.shared.cs @@ -0,0 +1,26 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Controllers; + +// IMPORTANT: An internal copy of this type exists in the SourceGenerators project. Keep these in sync when making changes. +[PublicAPI] +[Flags] +public enum JsonApiEndpoints +{ + None = 0, + GetCollection = 1, + GetSingle = 1 << 1, + GetSecondary = 1 << 2, + GetRelationship = 1 << 3, + Post = 1 << 4, + PostRelationship = 1 << 5, + Patch = 1 << 6, + PatchRelationship = 1 << 7, + Delete = 1 << 8, + DeleteRelationship = 1 << 9, + + Query = GetCollection | GetSingle | GetSecondary | GetRelationship, + Command = Post | PostRelationship | Patch | PatchRelationship | Delete | DeleteRelationship, + + All = Query | Command +} diff --git a/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj b/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj index 7c78f620ed..ed36e0797c 100644 --- a/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj +++ b/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj @@ -1,30 +1,50 @@ - $(TargetFrameworkName) + net8.0;netstandard1.0 true true JsonApiDotNetCore + + - $(JsonApiDotNetCoreVersionPrefix) jsonapidotnetcore;jsonapi;json:api;dotnet;asp.net;rest;web-api - Annotations for JsonApiDotNetCore, a framework for building JSON:API compliant REST APIs using ASP.NET and Entity Framework Core. + Annotations for JsonApiDotNetCore, which is a framework for building JSON:API compliant REST APIs using ASP.NET Core and Entity Framework Core. json-api-dotnet https://www.jsonapi.net/ MIT false See https://github.com/json-api-dotnet/JsonApiDotNetCore/releases. - logo.png + package-icon.png + PackageReadme.md true - true embedded - - True - - + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/JsonApiDotNetCore.Annotations/ObjectExtensions.cs b/src/JsonApiDotNetCore.Annotations/ObjectExtensions.cs deleted file mode 100644 index b7fd934fbe..0000000000 --- a/src/JsonApiDotNetCore.Annotations/ObjectExtensions.cs +++ /dev/null @@ -1,35 +0,0 @@ -#pragma warning disable AV1130 // Return type in method signature should be an interface to an unchangeable collection - -namespace JsonApiDotNetCore; - -internal static class ObjectExtensions -{ - public static IEnumerable AsEnumerable(this T element) - { - yield return element; - } - - public static T[] AsArray(this T element) - { - return new[] - { - element - }; - } - - public static List AsList(this T element) - { - return new List - { - element - }; - } - - public static HashSet AsHashSet(this T element) - { - return new HashSet - { - element - }; - } -} diff --git a/src/JsonApiDotNetCore.Annotations/PolyfillCollectionExtensions.cs b/src/JsonApiDotNetCore.Annotations/PolyfillCollectionExtensions.cs new file mode 100644 index 0000000000..efc51f4f17 --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/PolyfillCollectionExtensions.cs @@ -0,0 +1,12 @@ +using System.Collections.ObjectModel; + +namespace JsonApiDotNetCore; + +// These methods provide polyfills for lower .NET versions. +internal static class PolyfillCollectionExtensions +{ + public static IReadOnlySet AsReadOnly(this HashSet source) + { + return new ReadOnlySet(source); + } +} diff --git a/src/JsonApiDotNetCore.Annotations/Properties/AssemblyInfo.cs b/src/JsonApiDotNetCore.Annotations/Properties/AssemblyInfo.cs index 155a48c3c2..b4ac4b1e8d 100644 --- a/src/JsonApiDotNetCore.Annotations/Properties/AssemblyInfo.cs +++ b/src/JsonApiDotNetCore.Annotations/Properties/AssemblyInfo.cs @@ -1,7 +1,9 @@ using System.Runtime.CompilerServices; +[assembly: InternalsVisibleTo("DapperExample")] [assembly: InternalsVisibleTo("Benchmarks")] [assembly: InternalsVisibleTo("JsonApiDotNetCore")] +[assembly: InternalsVisibleTo("JsonApiDotNetCore.OpenApi.Swashbuckle")] [assembly: InternalsVisibleTo("JsonApiDotNetCoreTests")] [assembly: InternalsVisibleTo("UnitTests")] [assembly: InternalsVisibleTo("TestBuildingBlocks")] diff --git a/src/JsonApiDotNetCore.Annotations/ReadOnlySet.cs b/src/JsonApiDotNetCore.Annotations/ReadOnlySet.cs new file mode 100644 index 0000000000..9917af9ec7 --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/ReadOnlySet.cs @@ -0,0 +1,171 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#if NET8_0 +#pragma warning disable + +// ReadOnlySet was introduced in .NET 9. +// This file was copied from https://github.com/dotnet/runtime/blob/release/9.0/src/libraries/System.Collections/src/System/Collections/Generic/ReadOnlySet.cs +// and made internal to enable usage on lower .NET versions. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace System.Collections.ObjectModel; + +/// Represents a read-only, generic set of values. +/// The type of values in the set. +[DebuggerDisplay("Count = {Count}")] +[ExcludeFromCodeCoverage] +internal class ReadOnlySet : IReadOnlySet, ISet, ICollection +{ + /// The wrapped set. + private readonly ISet _set; + + /// Initializes a new instance of the class that is a wrapper around the specified set. + /// The set to wrap. + public ReadOnlySet(ISet set) + { + ArgumentNullException.ThrowIfNull(set); + _set = set; + } + + /// Gets an empty . + public static ReadOnlySet Empty { get; } = new ReadOnlySet(new HashSet()); + + /// Gets the set that is wrapped by this object. + protected ISet Set => _set; + + /// + public int Count => _set.Count; + + /// + public IEnumerator GetEnumerator() => + _set.Count == 0 ? ((IEnumerable)Array.Empty()).GetEnumerator() : + _set.GetEnumerator(); + + /// + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + /// + public bool Contains(T item) => _set.Contains(item); + + /// + public bool IsProperSubsetOf(IEnumerable other) => _set.IsProperSubsetOf(other); + + /// + public bool IsProperSupersetOf(IEnumerable other) => _set.IsProperSupersetOf(other); + + /// + public bool IsSubsetOf(IEnumerable other) => _set.IsSubsetOf(other); + + /// + public bool IsSupersetOf(IEnumerable other) => _set.IsSupersetOf(other); + + /// + public bool Overlaps(IEnumerable other) => _set.Overlaps(other); + + /// + public bool SetEquals(IEnumerable other) => _set.SetEquals(other); + + /// + void ICollection.CopyTo(T[] array, int arrayIndex) => _set.CopyTo(array, arrayIndex); + + /// + void ICollection.CopyTo(Array array, int index) => CollectionHelpers.CopyTo(_set, array, index); + + /// + bool ICollection.IsReadOnly => true; + + /// + bool ICollection.IsSynchronized => false; + + /// + object ICollection.SyncRoot => _set is ICollection c ? c.SyncRoot : this; + + /// + bool ISet.Add(T item) => throw new NotSupportedException(); + + /// + void ISet.ExceptWith(IEnumerable other) => throw new NotSupportedException(); + + /// + void ISet.IntersectWith(IEnumerable other) => throw new NotSupportedException(); + + /// + void ISet.SymmetricExceptWith(IEnumerable other) => throw new NotSupportedException(); + + /// + void ISet.UnionWith(IEnumerable other) => throw new NotSupportedException(); + + /// + void ICollection.Add(T item) => throw new NotSupportedException(); + + /// + void ICollection.Clear() => throw new NotSupportedException(); + + /// + bool ICollection.Remove(T item) => throw new NotSupportedException(); + + private static class CollectionHelpers + { + private static void ValidateCopyToArguments(int sourceCount, Array array, int index) + { + ArgumentNullException.ThrowIfNull(array); + + if (array.Rank != 1) + { + throw new ArgumentException("Only single dimensional arrays are supported for the requested action.", nameof(array)); + } + + if (array.GetLowerBound(0) != 0) + { + throw new ArgumentException("The lower bound of target array must be zero.", nameof(array)); + } + + ArgumentOutOfRangeException.ThrowIfNegative(index); + ArgumentOutOfRangeException.ThrowIfGreaterThan(index, array.Length); + + if (array.Length - index < sourceCount) + { + throw new ArgumentException("Destination array is not long enough to copy all the items in the collection. Check array index and length."); + } + } + + internal static void CopyTo(ICollection collection, Array array, int index) + { + ValidateCopyToArguments(collection.Count, array, index); + + if (collection is ICollection nonGenericCollection) + { + // Easy out if the ICollection implements the non-generic ICollection + nonGenericCollection.CopyTo(array, index); + } + else if (array is T[] items) + { + collection.CopyTo(items, index); + } + else + { + // We can't cast array of value type to object[], so we don't support widening of primitive types here. + if (array is not object?[] objects) + { + throw new ArgumentException("Target array type is not compatible with the type of items in the collection.", nameof(array)); + } + + try + { + foreach (T item in collection) + { + objects[index++] = item; + } + } + catch (ArrayTypeMismatchException) + { + throw new ArgumentException("Target array type is not compatible with the type of items in the collection.", nameof(array)); + } + } + } + } +} +#endif diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs index d3d6133f6e..26a660775a 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs @@ -14,17 +14,16 @@ public sealed class AttrAttribute : ResourceFieldAttribute internal bool HasExplicitCapabilities => _capabilities != null; /// - /// The set of capabilities that are allowed to be performed on this attribute. When not explicitly assigned, the configured default set of capabilities - /// is used. + /// The set of allowed capabilities on this attribute. When not explicitly set, the configured default set of capabilities is used. /// /// - /// - /// public class Author : Identifiable + /// /// { /// [Attr(Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort)] - /// public string Name { get; set; } + /// public string Name { get; set; } = null!; /// } - /// + /// ]]> /// public AttrCapabilities Capabilities { @@ -32,6 +31,7 @@ public AttrCapabilities Capabilities set => _capabilities = value; } + /// public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) @@ -49,6 +49,7 @@ public override bool Equals(object? obj) return Capabilities == other.Capabilities && base.Equals(other); } + /// public override int GetHashCode() { return HashCode.Combine(Capabilities, base.GetHashCode()); diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.netstandard.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.netstandard.cs new file mode 100644 index 0000000000..a7915240dc --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.netstandard.cs @@ -0,0 +1,14 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Resources.Annotations; + +/// +/// A simplified version, provided for convenience to multi-target against NetStandard. Does not actually work with JsonApiDotNetCore. +/// +[PublicAPI] +[AttributeUsage(AttributeTargets.Property)] +public sealed class AttrAttribute : ResourceFieldAttribute +{ + /// + public AttrCapabilities Capabilities { get; set; } +} diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrCapabilities.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrCapabilities.cs deleted file mode 100644 index c6f849fdff..0000000000 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrCapabilities.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace JsonApiDotNetCore.Resources.Annotations; - -/// -/// Indicates capabilities that can be performed on an . -/// -[Flags] -public enum AttrCapabilities -{ - None = 0, - - /// - /// Whether or not GET requests can retrieve the attribute. Attempts to retrieve when disabled will return an HTTP 400 response. - /// - AllowView = 1, - - /// - /// Whether or not POST requests can assign the attribute value. Attempts to assign when disabled will return an HTTP 422 response. - /// - AllowCreate = 2, - - /// - /// Whether or not PATCH requests can update the attribute value. Attempts to update when disabled will return an HTTP 422 response. - /// - AllowChange = 4, - - /// - /// Whether or not an attribute can be filtered on via a query string parameter. Attempts to filter when disabled will return an HTTP 400 response. - /// - AllowFilter = 8, - - /// - /// Whether or not an attribute can be sorted on via a query string parameter. Attempts to sort when disabled will return an HTTP 400 response. - /// - AllowSort = 16, - - All = AllowView | AllowCreate | AllowChange | AllowFilter | AllowSort -} diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrCapabilities.shared.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrCapabilities.shared.cs new file mode 100644 index 0000000000..0951010b3b --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrCapabilities.shared.cs @@ -0,0 +1,43 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Resources.Annotations; + +/// +/// Indicates what can be performed on an . +/// +[PublicAPI] +[Flags] +public enum AttrCapabilities +{ + None = 0, + + /// + /// Whether or not the attribute value can be returned in responses. Attempts to explicitly request it via the fields query string parameter when + /// disabled will return an HTTP 400 response. Otherwise, the attribute is silently omitted. + /// + AllowView = 1, + + /// + /// Whether or not POST requests can assign the attribute value. Attempts to assign when disabled will return an HTTP 422 response. + /// + AllowCreate = 1 << 1, + + /// + /// Whether or not PATCH requests can update the attribute value. Attempts to update when disabled will return an HTTP 422 response. + /// + AllowChange = 1 << 2, + + /// + /// Whether or not the attribute can be filtered on. Attempts to use it in the filter query string parameter when disabled will return an HTTP 400 + /// response. + /// + AllowFilter = 1 << 3, + + /// + /// Whether or not the attribute can be sorted on. Attempts to use it in the sort query string parameter when disabled will return an HTTP 400 + /// response. + /// + AllowSort = 1 << 4, + + All = AllowView | AllowCreate | AllowChange | AllowFilter | AllowSort +} diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/EagerLoadAttribute.netstandard.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/EagerLoadAttribute.netstandard.cs new file mode 100644 index 0000000000..3083d7f436 --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/EagerLoadAttribute.netstandard.cs @@ -0,0 +1,10 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Resources.Annotations; + +/// +/// A simplified version, provided for convenience to multi-target against NetStandard. Does not actually work with JsonApiDotNetCore. +/// +[PublicAPI] +[AttributeUsage(AttributeTargets.Property)] +public sealed class EagerLoadAttribute : Attribute; diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs index 39bcf34b3f..a906f4a667 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs @@ -1,5 +1,8 @@ +using System.Collections; using JetBrains.Annotations; +// ReSharper disable NonReadonlyMemberInGetHashCode + namespace JsonApiDotNetCore.Resources.Annotations; /// @@ -20,12 +23,33 @@ namespace JsonApiDotNetCore.Resources.Annotations; public sealed class HasManyAttribute : RelationshipAttribute { private readonly Lazy _lazyIsManyToMany; + private HasManyCapabilities? _capabilities; /// /// Inspects to determine if this is a many-to-many relationship. /// internal bool IsManyToMany => _lazyIsManyToMany.Value; + internal bool HasExplicitCapabilities => _capabilities != null; + + /// + /// The set of allowed capabilities on this to-many relationship. When not explicitly set, the configured default set of capabilities is used. + /// + /// + /// + /// { + /// [HasMany(Capabilities = HasManyCapabilities.AllowView | HasManyCapabilities.AllowInclude)] + /// public ISet Chapters { get; set; } = new HashSet(); + /// } + /// ]]> + /// + public HasManyCapabilities Capabilities + { + get => _capabilities ?? default; + set => _capabilities = value; + } + public HasManyAttribute() { _lazyIsManyToMany = new Lazy(EvaluateIsManyToMany, LazyThreadSafetyMode.PublicationOnly); @@ -35,10 +59,83 @@ private bool EvaluateIsManyToMany() { if (InverseNavigationProperty != null) { - Type? elementType = CollectionConverter.FindCollectionElementType(InverseNavigationProperty.PropertyType); + Type? elementType = CollectionConverter.Instance.FindCollectionElementType(InverseNavigationProperty.PropertyType); return elementType != null; } return false; } + + /// + public override void SetValue(object resource, object? newValue) + { + ArgumentNullException.ThrowIfNull(newValue); + AssertIsIdentifiableCollection(newValue); + + base.SetValue(resource, newValue); + } + + private void AssertIsIdentifiableCollection(object newValue) + { + if (newValue is not IEnumerable enumerable) + { + throw new InvalidOperationException($"Resource of type '{newValue.GetType()}' must be a collection."); + } + + foreach (object? element in enumerable) + { + if (element == null) + { + throw new InvalidOperationException("Resource collection must not contain null values."); + } + + AssertIsIdentifiable(element); + } + } + + /// + /// Adds a resource to this to-many relationship on the specified resource instance. Throws if the property is read-only or if the field does not belong + /// to the specified resource instance. + /// + public void AddValue(object resource, IIdentifiable resourceToAdd) + { + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(resourceToAdd); + + object? rightValue = GetValue(resource); + List rightResources = CollectionConverter.Instance.ExtractResources(rightValue).ToList(); + + if (!rightResources.Exists(nextResource => nextResource == resourceToAdd)) + { + rightResources.Add(resourceToAdd); + + Type collectionType = rightValue?.GetType() ?? Property.PropertyType; + IEnumerable typedCollection = CollectionConverter.Instance.CopyToTypedCollection(rightResources, collectionType); + base.SetValue(resource, typedCollection); + } + } + + /// + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj is null || GetType() != obj.GetType()) + { + return false; + } + + var other = (HasManyAttribute)obj; + + return _capabilities == other._capabilities && base.Equals(other); + } + + /// + public override int GetHashCode() + { + return HashCode.Combine(_capabilities, base.GetHashCode()); + } } diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.netstandard.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.netstandard.cs new file mode 100644 index 0000000000..cf83f0ce17 --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.netstandard.cs @@ -0,0 +1,14 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Resources.Annotations; + +/// +/// A simplified version, provided for convenience to multi-target against NetStandard. Does not actually work with JsonApiDotNetCore. +/// +[PublicAPI] +[AttributeUsage(AttributeTargets.Property)] +public sealed class HasManyAttribute : RelationshipAttribute +{ + /// + public HasManyCapabilities Capabilities { get; set; } +} diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyCapabilities.shared.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyCapabilities.shared.cs new file mode 100644 index 0000000000..cf65951321 --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyCapabilities.shared.cs @@ -0,0 +1,51 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Resources.Annotations; + +/// +/// Indicates what can be performed on a . +/// +[PublicAPI] +[Flags] +public enum HasManyCapabilities +{ + None = 0, + + /// + /// Whether or not the relationship can be returned in responses. Attempts to explicitly request it via the fields query string parameter when + /// disabled will return an HTTP 400 response. Otherwise, the relationship (and its related resources, when included) are silently omitted. + /// + /// + /// Note this setting does not affect retrieving the related resources directly. + /// + AllowView = 1, + + /// + /// Whether or not the relationship can be included. Attempts to use it in the include query string parameter when disabled will return an HTTP + /// 400 response. + /// + AllowInclude = 1 << 1, + + /// + /// Whether or not the to-many relationship can be used in the count() and has() functions as part of the filter query string + /// parameter. Attempts to use it when disabled will return an HTTP 400 response. + /// + AllowFilter = 1 << 2, + + /// + /// Whether or not POST and PATCH requests can replace the relationship. Attempts to replace when disabled will return an HTTP 422 response. + /// + AllowSet = 1 << 3, + + /// + /// Whether or not POST requests can add to the to-many relationship. Attempts to add when disabled will return an HTTP 422 response. + /// + AllowAdd = 1 << 4, + + /// + /// Whether or not DELETE requests can remove from the to-many relationship. Attempts to remove when disabled will return an HTTP 422 response. + /// + AllowRemove = 1 << 5, + + All = AllowView | AllowInclude | AllowFilter | AllowSet | AllowAdd | AllowRemove +} diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneAttribute.cs index 0a68f702d3..51d22f9955 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneAttribute.cs @@ -1,5 +1,7 @@ using JetBrains.Annotations; +// ReSharper disable NonReadonlyMemberInGetHashCode + namespace JsonApiDotNetCore.Resources.Annotations; /// @@ -19,12 +21,33 @@ namespace JsonApiDotNetCore.Resources.Annotations; public sealed class HasOneAttribute : RelationshipAttribute { private readonly Lazy _lazyIsOneToOne; + private HasOneCapabilities? _capabilities; /// /// Inspects to determine if this is a one-to-one relationship. /// internal bool IsOneToOne => _lazyIsOneToOne.Value; + internal bool HasExplicitCapabilities => _capabilities != null; + + /// + /// The set of allowed capabilities on this to-one relationship. When not explicitly set, the configured default set of capabilities is used. + /// + /// + /// + /// { + /// [HasOne(Capabilities = HasOneCapabilities.AllowView | HasOneCapabilities.AllowInclude)] + /// public Person? Author { get; set; } + /// } + /// ]]> + /// + public HasOneCapabilities Capabilities + { + get => _capabilities ?? default; + set => _capabilities = value; + } + public HasOneAttribute() { _lazyIsOneToOne = new Lazy(EvaluateIsOneToOne, LazyThreadSafetyMode.PublicationOnly); @@ -34,10 +57,41 @@ private bool EvaluateIsOneToOne() { if (InverseNavigationProperty != null) { - Type? elementType = CollectionConverter.FindCollectionElementType(InverseNavigationProperty.PropertyType); + Type? elementType = CollectionConverter.Instance.FindCollectionElementType(InverseNavigationProperty.PropertyType); return elementType == null; } return false; } + + /// + public override void SetValue(object resource, object? newValue) + { + AssertIsIdentifiable(newValue); + base.SetValue(resource, newValue); + } + + /// + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj is null || GetType() != obj.GetType()) + { + return false; + } + + var other = (HasOneAttribute)obj; + + return _capabilities == other._capabilities && base.Equals(other); + } + + /// + public override int GetHashCode() + { + return HashCode.Combine(_capabilities, base.GetHashCode()); + } } diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneAttribute.netstandard.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneAttribute.netstandard.cs new file mode 100644 index 0000000000..42be2f3c5f --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneAttribute.netstandard.cs @@ -0,0 +1,14 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Resources.Annotations; + +/// +/// A simplified version, provided for convenience to multi-target against NetStandard. Does not actually work with JsonApiDotNetCore. +/// +[PublicAPI] +[AttributeUsage(AttributeTargets.Property)] +public sealed class HasOneAttribute : RelationshipAttribute +{ + /// + public HasOneCapabilities Capabilities { get; set; } +} diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneCapabilities.shared.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneCapabilities.shared.cs new file mode 100644 index 0000000000..a001e39407 --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneCapabilities.shared.cs @@ -0,0 +1,35 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Resources.Annotations; + +/// +/// Indicates what can be performed on a . +/// +[PublicAPI] +[Flags] +public enum HasOneCapabilities +{ + None = 0, + + /// + /// Whether or not the relationship can be returned in responses. Attempts to explicitly request it via the fields query string parameter when + /// disabled will return an HTTP 400 response. Otherwise, the relationship (and its related resources, when included) are silently omitted. + /// + /// + /// Note this setting does not affect retrieving the related resources directly. + /// + AllowView = 1, + + /// + /// Whether or not the relationship can be included. Attempts to use it in the include query string parameter when disabled will return an HTTP + /// 400 response. + /// + AllowInclude = 1 << 1, + + /// + /// Whether or not POST and PATCH requests can replace the relationship. Attempts to replace when disabled will return an HTTP 422 response. + /// + AllowSet = 1 << 2, + + All = AllowView | AllowInclude | AllowSet +} diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/LinkTypes.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/LinkTypes.cs deleted file mode 100644 index 61c7e9d927..0000000000 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/LinkTypes.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace JsonApiDotNetCore.Resources.Annotations; - -[Flags] -public enum LinkTypes -{ - Self = 1 << 0, - Related = 1 << 1, - Paging = 1 << 2, - NotConfigured = 1 << 3, - None = 1 << 4, - All = Self | Related | Paging -} diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/LinkTypes.shared.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/LinkTypes.shared.cs new file mode 100644 index 0000000000..632b8b9ed3 --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/LinkTypes.shared.cs @@ -0,0 +1,13 @@ +namespace JsonApiDotNetCore.Resources.Annotations; + +[Flags] +public enum LinkTypes +{ + NotConfigured = 0, + None = 1 << 0, + Self = 1 << 1, + Related = 1 << 2, + DescribedBy = 1 << 3, + Pagination = 1 << 4, + All = Self | Related | DescribedBy | Pagination +} diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/NoResourceAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/NoResourceAttribute.cs deleted file mode 100644 index 8be53b0d03..0000000000 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/NoResourceAttribute.cs +++ /dev/null @@ -1,13 +0,0 @@ -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Resources.Annotations; - -/// -/// When put on an Entity Framework Core entity, indicates that the type should not be added to the resource graph. This effectively suppresses the -/// warning at startup that this type does not implement . -/// -[PublicAPI] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] -public sealed class NoResourceAttribute : Attribute -{ -} diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/NoResourceAttribute.shared.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/NoResourceAttribute.shared.cs new file mode 100644 index 0000000000..02d19761d7 --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/NoResourceAttribute.shared.cs @@ -0,0 +1,11 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Resources.Annotations; + +/// +/// When put on an Entity Framework Core entity, indicates that the type should not be added to the resource graph. This effectively suppresses the +/// warning at startup that this type does not implement . +/// +[PublicAPI] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +public sealed class NoResourceAttribute : Attribute; diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs index 11320a7abc..21d5bfab1d 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs @@ -12,11 +12,13 @@ namespace JsonApiDotNetCore.Resources.Annotations; [PublicAPI] public abstract class RelationshipAttribute : ResourceFieldAttribute { - private protected static readonly CollectionConverter CollectionConverter = new(); - - // These are definitely assigned after building the resource graph, which is why their public equivalents are declared as non-nullable. + // This field is definitely assigned after building the resource graph, which is why its public equivalent is declared as non-nullable. private ResourceType? _rightType; + private bool? _canInclude; + + internal bool HasExplicitCanInclude => _canInclude != null; + /// /// The of the Entity Framework Core inverse navigation, which may or may not exist. Even if it exists, it may not be exposed /// as a JSON:API relationship. @@ -57,7 +59,7 @@ public ResourceType RightType get => _rightType!; internal set { - ArgumentGuard.NotNull(value, nameof(value)); + ArgumentNullException.ThrowIfNull(value); _rightType = value; } } @@ -66,17 +68,23 @@ internal set /// Configures which links to write in the relationship-level links object for this relationship. Defaults to , /// which falls back to and then falls back to RelationshipLinks in global options. /// - public LinkTypes Links { get; set; } = LinkTypes.NotConfigured; + public LinkTypes Links { get; set; } /// - /// Whether or not this relationship can be included using the - /// - /// ?include=publicName - /// - /// query string parameter. This is true by default. + /// Whether or not this relationship can be included using the include query string parameter. This is true by default. /// - public bool CanInclude { get; set; } = true; + /// + /// When explicitly set, this value takes precedence over Capabilities for backwards-compatibility. Capabilities are adjusted accordingly when building + /// the resource graph. + /// + [Obsolete("Use AllowInclude in Capabilities instead.")] + public bool CanInclude + { + get => _canInclude ?? true; + set => _canInclude = value; + } + /// public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) @@ -91,11 +99,12 @@ public override bool Equals(object? obj) var other = (RelationshipAttribute)obj; - return _rightType?.ClrType == other._rightType?.ClrType && Links == other.Links && CanInclude == other.CanInclude && base.Equals(other); + return _rightType?.ClrType == other._rightType?.ClrType && Links == other.Links && base.Equals(other); } + /// public override int GetHashCode() { - return HashCode.Combine(_rightType?.ClrType, Links, CanInclude, base.GetHashCode()); + return HashCode.Combine(_rightType?.ClrType, Links, base.GetHashCode()); } } diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.netstandard.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.netstandard.cs new file mode 100644 index 0000000000..054d7b1af3 --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.netstandard.cs @@ -0,0 +1,17 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Resources.Annotations; + +/// +/// A simplified version, provided for convenience to multi-target against NetStandard. Does not actually work with JsonApiDotNetCore. +/// +[PublicAPI] +public abstract class RelationshipAttribute : ResourceFieldAttribute +{ + /// + public LinkTypes Links { get; set; } + + /// + [Obsolete("Use AllowInclude in Capabilities instead.")] + public bool CanInclude { get; set; } = true; +} diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceAttribute.cs deleted file mode 100644 index ca517e3e99..0000000000 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceAttribute.cs +++ /dev/null @@ -1,32 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Controllers; - -// ReSharper disable CheckNamespace -#pragma warning disable AV1505 // Namespace should match with assembly name - -namespace JsonApiDotNetCore.Resources.Annotations; - -/// -/// When put on a resource class, overrides the convention-based public resource name and auto-generates an ASP.NET controller. -/// -[PublicAPI] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] -public sealed class ResourceAttribute : Attribute -{ - /// - /// Optional. The publicly exposed name of this resource type. - /// - public string? PublicName { get; set; } - - /// - /// The set of endpoints to auto-generate an ASP.NET controller for. Defaults to . Set to - /// to disable controller generation. - /// - public JsonApiEndpoints GenerateControllerEndpoints { get; set; } = JsonApiEndpoints.All; - - /// - /// Optional. The full namespace in which to auto-generate the ASP.NET controller. Defaults to the sibling namespace "Controllers". For example, a - /// resource class that is declared in namespace "ExampleCompany.ExampleApi.Models" will use "ExampleCompany.ExampleApi.Controllers" by default. - /// - public string? ControllerNamespace { get; set; } -} diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceAttribute.shared.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceAttribute.shared.cs new file mode 100644 index 0000000000..e3f4ce97b5 --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceAttribute.shared.cs @@ -0,0 +1,42 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; + +namespace JsonApiDotNetCore.Resources.Annotations; + +/// +/// When put on a resource class, overrides the convention-based public resource name and auto-generates an ASP.NET controller. +/// +[PublicAPI] +[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. + /// + public JsonApiEndpoints GenerateControllerEndpoints { get; set; } = JsonApiEndpoints.All; + + /// + /// Optional. The full namespace in which to auto-generate the ASP.NET controller. Defaults to the sibling namespace "Controllers". For example, a + /// resource class that is declared in namespace "ExampleCompany.ExampleApi.Models" will use "ExampleCompany.ExampleApi.Controllers" by default. + /// + public string? ControllerNamespace { get; set; } +} diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs index 599b17a42a..593b0a905d 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs @@ -19,7 +19,7 @@ public abstract class ResourceFieldAttribute : Attribute private ResourceType? _type; /// - /// The publicly exposed name of this JSON:API field. When not explicitly assigned, the configured naming convention is applied on the property name. + /// The publicly exposed name of this JSON:API field. When not explicitly set, the configured naming convention is applied on the property name. /// public string PublicName { @@ -43,7 +43,7 @@ public PropertyInfo Property get => _property!; internal set { - ArgumentGuard.NotNull(value, nameof(value)); + ArgumentNullException.ThrowIfNull(value); _property = value; } } @@ -56,7 +56,7 @@ public ResourceType Type get => _type!; internal set { - ArgumentGuard.NotNull(value, nameof(value)); + ArgumentNullException.ThrowIfNull(value); _type = value; } } @@ -67,7 +67,8 @@ internal set /// public object? GetValue(object resource) { - ArgumentGuard.NotNull(resource, nameof(resource)); + ArgumentNullException.ThrowIfNull(resource); + AssertIsIdentifiable(resource); if (Property.GetMethod == null) { @@ -82,7 +83,7 @@ internal set { throw new InvalidOperationException( $"Unable to get property value of '{Property.DeclaringType!.Name}.{Property.Name}' on instance of type '{resource.GetType().Name}'.", - exception); + exception.InnerException ?? exception); } } @@ -90,9 +91,10 @@ internal set /// Sets the value of this field on the specified resource instance. Throws if the property is read-only or if the field does not belong to the specified /// resource instance. /// - public void SetValue(object resource, object? newValue) + public virtual void SetValue(object resource, object? newValue) { - ArgumentGuard.NotNull(resource, nameof(resource)); + ArgumentNullException.ThrowIfNull(resource); + AssertIsIdentifiable(resource); if (Property.SetMethod == null) { @@ -107,15 +109,27 @@ public void SetValue(object resource, object? newValue) { throw new InvalidOperationException( $"Unable to set property value of '{Property.DeclaringType!.Name}.{Property.Name}' on instance of type '{resource.GetType().Name}'.", - exception); + exception.InnerException ?? exception); } } + protected void AssertIsIdentifiable(object? resource) + { + if (resource is not null and not IIdentifiable) + { +#pragma warning disable CA1062 // Validate arguments of public methods + throw new InvalidOperationException($"Resource of type '{resource.GetType()}' does not implement {nameof(IIdentifiable)}."); +#pragma warning restore CA1062 // Validate arguments of public methods + } + } + + /// public override string? ToString() { return _publicName ?? (_property != null ? _property.Name : base.ToString()); } + /// public override bool Equals(object? obj) { if (ReferenceEquals(this, obj)) @@ -133,6 +147,7 @@ public override bool Equals(object? obj) return _publicName == other._publicName && _property == other._property; } + /// public override int GetHashCode() { return HashCode.Combine(_publicName, _property); diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.netstandard.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.netstandard.cs new file mode 100644 index 0000000000..958794365d --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.netstandard.cs @@ -0,0 +1,13 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Resources.Annotations; + +/// +/// A simplified version, provided for convenience to multi-target against NetStandard. Does not actually work with JsonApiDotNetCore. +/// +[PublicAPI] +public abstract class ResourceFieldAttribute : Attribute +{ + /// + public string PublicName { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceLinksAttribute.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceLinksAttribute.cs deleted file mode 100644 index 010f87db5e..0000000000 --- a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceLinksAttribute.cs +++ /dev/null @@ -1,30 +0,0 @@ -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Resources.Annotations; - -/// -/// When put on a resource class, overrides global configuration for which links to render. -/// -[PublicAPI] -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] -public sealed class ResourceLinksAttribute : Attribute -{ - /// - /// Configures which links to write in the top-level links object for this resource type. Defaults to , which falls - /// back to TopLevelLinks in global options. - /// - public LinkTypes TopLevelLinks { get; set; } = LinkTypes.NotConfigured; - - /// - /// Configures which links to write in the resource-level links object for this resource type. Defaults to , which - /// falls back to ResourceLinks in global options. - /// - public LinkTypes ResourceLinks { get; set; } = LinkTypes.NotConfigured; - - /// - /// Configures which links to write in the relationship-level links object for all relationships of this resource type. Defaults to - /// , which falls back to RelationshipLinks in global options. This can be overruled per relationship by setting - /// . - /// - public LinkTypes RelationshipLinks { get; set; } = LinkTypes.NotConfigured; -} diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceLinksAttribute.shared.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceLinksAttribute.shared.cs new file mode 100644 index 0000000000..2c2c353f3a --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceLinksAttribute.shared.cs @@ -0,0 +1,30 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Resources.Annotations; + +/// +/// When put on a resource class, overrides global configuration for which links to render. +/// +[PublicAPI] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +public sealed class ResourceLinksAttribute : Attribute +{ + /// + /// Configures which links to write in the top-level links object for this resource type. Defaults to , which falls + /// back to TopLevelLinks in global options. + /// + public LinkTypes TopLevelLinks { get; set; } + + /// + /// Configures which links to write in the resource-level links object for this resource type. Defaults to , which + /// falls back to ResourceLinks in global options. + /// + public LinkTypes ResourceLinks { get; set; } + + /// + /// Configures which links to write in the relationship-level links object for all relationships of this resource type. Defaults to + /// , which falls back to RelationshipLinks in global options. This can be overruled per relationship by setting + /// . + /// + public LinkTypes RelationshipLinks { get; set; } +} diff --git a/src/JsonApiDotNetCore.Annotations/Resources/IIdentifiable.cs b/src/JsonApiDotNetCore.Annotations/Resources/IIdentifiable.cs deleted file mode 100644 index 2c6dc02025..0000000000 --- a/src/JsonApiDotNetCore.Annotations/Resources/IIdentifiable.cs +++ /dev/null @@ -1,31 +0,0 @@ -namespace JsonApiDotNetCore.Resources; - -/// -/// Defines the basic contract for a JSON:API resource. All resource classes must implement . -/// -public interface IIdentifiable -{ - /// - /// The value for element 'id' in a JSON:API request or response. - /// - string? StringId { get; set; } - - /// - /// The value for element 'lid' in a JSON:API request. - /// - string? LocalId { get; set; } -} - -/// -/// When implemented by a class, indicates to JsonApiDotNetCore that the class represents a JSON:API resource. -/// -/// -/// The resource identifier type. -/// -public interface IIdentifiable : IIdentifiable -{ - /// - /// The typed identifier as used by the underlying data store (usually numeric or Guid). - /// - TId Id { get; set; } -} diff --git a/src/JsonApiDotNetCore.Annotations/Resources/IIdentifiable.shared.cs b/src/JsonApiDotNetCore.Annotations/Resources/IIdentifiable.shared.cs new file mode 100644 index 0000000000..b6dc5e3b22 --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Resources/IIdentifiable.shared.cs @@ -0,0 +1,35 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Resources; + +/// +/// Defines the basic contract for a JSON:API resource. All resource classes must implement . +/// +[PublicAPI] +public interface IIdentifiable +{ + /// + /// The value for element 'id' in a JSON:API request or response. + /// + string? StringId { get; set; } + + /// + /// The value for element 'lid' in a JSON:API request. + /// + string? LocalId { get; set; } +} + +/// +/// When implemented by a class, indicates to JsonApiDotNetCore that the class represents a JSON:API resource. +/// +/// +/// The resource identifier type. +/// +[PublicAPI] +public interface IIdentifiable : IIdentifiable +{ + /// + /// The typed identifier as used by the underlying data store (usually numeric or Guid). + /// + TId Id { get; set; } +} diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Identifiable.cs b/src/JsonApiDotNetCore.Annotations/Resources/Identifiable.cs index bb854dac23..c129b4fdab 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/Identifiable.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/Identifiable.cs @@ -1,5 +1,4 @@ using System.ComponentModel.DataAnnotations.Schema; -using JsonApiDotNetCore.Resources.Internal; namespace JsonApiDotNetCore.Resources; diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Identifiable.netstandard.cs b/src/JsonApiDotNetCore.Annotations/Resources/Identifiable.netstandard.cs new file mode 100644 index 0000000000..a5a7179af6 --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Resources/Identifiable.netstandard.cs @@ -0,0 +1,16 @@ +namespace JsonApiDotNetCore.Resources; + +/// +/// A simplified version, provided for convenience to multi-target against NetStandard. Does not actually work with JsonApiDotNetCore. +/// +public abstract class Identifiable : IIdentifiable +{ + /// + public virtual TId Id { get; set; } = default!; + + /// + public string? StringId { get; set; } + + /// + public string? LocalId { get; set; } +} diff --git a/src/JsonApiDotNetCore.Annotations/Resources/Internal/RuntimeTypeConverter.cs b/src/JsonApiDotNetCore.Annotations/Resources/Internal/RuntimeTypeConverter.cs deleted file mode 100644 index 8722458938..0000000000 --- a/src/JsonApiDotNetCore.Annotations/Resources/Internal/RuntimeTypeConverter.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System.Globalization; -using JetBrains.Annotations; - -#pragma warning disable AV1008 // Class should not be static - -namespace JsonApiDotNetCore.Resources.Internal; - -[PublicAPI] -public static class RuntimeTypeConverter -{ - public static object? ConvertType(object? value, Type type) - { - ArgumentGuard.NotNull(type, nameof(type)); - - if (value == null) - { - if (!CanContainNull(type)) - { - string targetTypeName = type.GetFriendlyTypeName(); - throw new FormatException($"Failed to convert 'null' to type '{targetTypeName}'."); - } - - return null; - } - - Type runtimeType = value.GetType(); - - if (type == runtimeType || type.IsAssignableFrom(runtimeType)) - { - return value; - } - - string? stringValue = value.ToString(); - - if (string.IsNullOrEmpty(stringValue)) - { - return GetDefaultValue(type); - } - - bool isNullableTypeRequested = type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>); - Type nonNullableType = Nullable.GetUnderlyingType(type) ?? type; - - try - { - if (nonNullableType == typeof(Guid)) - { - Guid convertedValue = Guid.Parse(stringValue); - return isNullableTypeRequested ? (Guid?)convertedValue : convertedValue; - } - - if (nonNullableType == typeof(DateTime)) - { - DateTime convertedValue = DateTime.Parse(stringValue, null, DateTimeStyles.RoundtripKind); - return isNullableTypeRequested ? (DateTime?)convertedValue : convertedValue; - } - - if (nonNullableType == typeof(DateTimeOffset)) - { - DateTimeOffset convertedValue = DateTimeOffset.Parse(stringValue, null, DateTimeStyles.RoundtripKind); - return isNullableTypeRequested ? (DateTimeOffset?)convertedValue : convertedValue; - } - - if (nonNullableType == typeof(TimeSpan)) - { - TimeSpan convertedValue = TimeSpan.Parse(stringValue); - return isNullableTypeRequested ? (TimeSpan?)convertedValue : convertedValue; - } - - if (nonNullableType.IsEnum) - { - object convertedValue = Enum.Parse(nonNullableType, stringValue); - - // https://bradwilson.typepad.com/blog/2008/07/creating-nullab.html - return convertedValue; - } - - // https://bradwilson.typepad.com/blog/2008/07/creating-nullab.html - return Convert.ChangeType(stringValue, nonNullableType); - } - catch (Exception exception) when (exception is FormatException or OverflowException or InvalidCastException or ArgumentException) - { - string runtimeTypeName = runtimeType.GetFriendlyTypeName(); - string targetTypeName = type.GetFriendlyTypeName(); - - throw new FormatException($"Failed to convert '{value}' of type '{runtimeTypeName}' to type '{targetTypeName}'.", exception); - } - } - - public static bool CanContainNull(Type type) - { - return !type.IsValueType || Nullable.GetUnderlyingType(type) != null; - } - - public static object? GetDefaultValue(Type type) - { - return type.IsValueType ? Activator.CreateInstance(type) : null; - } -} diff --git a/src/JsonApiDotNetCore.Annotations/Resources/RuntimeTypeConverter.cs b/src/JsonApiDotNetCore.Annotations/Resources/RuntimeTypeConverter.cs new file mode 100644 index 0000000000..74a4eb877c --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Resources/RuntimeTypeConverter.cs @@ -0,0 +1,160 @@ +using System.Collections.Concurrent; +using System.Globalization; +using JetBrains.Annotations; + +#pragma warning disable AV1008 // Class should not be static + +namespace JsonApiDotNetCore.Resources; + +/// +/// Provides utilities regarding runtime types. +/// +[PublicAPI] +public static class RuntimeTypeConverter +{ + private const string ParseQueryStringsUsingCurrentCultureSwitchName = "JsonApiDotNetCore.ParseQueryStringsUsingCurrentCulture"; + + private static readonly ConcurrentDictionary DefaultTypeCache = new(); + + /// + /// Converts the specified value to the specified type. + /// + /// + /// The value to convert from. + /// + /// + /// The type to convert to. + /// + /// + /// The converted type, or null if is null and is a nullable type. + /// + /// + /// is not compatible with . + /// + public static object? ConvertType(object? value, Type type) + { + ArgumentNullException.ThrowIfNull(type); + + // Earlier versions of JsonApiDotNetCore failed to pass CultureInfo.InvariantCulture in the parsing below, which resulted in the 'current' + // culture being used. Unlike parsing JSON request/response bodies, this effectively meant that query strings were parsed based on the + // OS-level regional settings of the web server. + // Because this was fixed in a non-major release, the switch below enables to revert to the old behavior. + + // With the switch activated, API developers can still choose between: + // - Requiring localized date/number formats: parsing occurs using the OS-level regional settings (the default). + // - Requiring culture-invariant date/number formats: requires setting CultureInfo.DefaultThreadCurrentCulture to CultureInfo.InvariantCulture at startup. + // - Allowing clients to choose by sending an Accept-Language HTTP header: requires app.UseRequestLocalization() at startup. + + CultureInfo? cultureInfo = AppContext.TryGetSwitch(ParseQueryStringsUsingCurrentCultureSwitchName, out bool useCurrentCulture) && useCurrentCulture + ? null + : CultureInfo.InvariantCulture; + + if (value == null) + { + if (!CanContainNull(type)) + { + string targetTypeName = type.GetFriendlyTypeName(); + throw new FormatException($"Failed to convert 'null' to type '{targetTypeName}'."); + } + + return null; + } + + Type runtimeType = value.GetType(); + + if (type == runtimeType || type.IsAssignableFrom(runtimeType)) + { + return value; + } + + string? stringValue = value is IFormattable cultureAwareValue ? cultureAwareValue.ToString(null, cultureInfo) : value.ToString(); + + if (string.IsNullOrEmpty(stringValue)) + { + return GetDefaultValue(type); + } + + bool isNullableTypeRequested = type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>); + Type nonNullableType = Nullable.GetUnderlyingType(type) ?? type; + + try + { + if (nonNullableType == typeof(Guid)) + { + Guid convertedValue = Guid.Parse(stringValue); + return isNullableTypeRequested ? (Guid?)convertedValue : convertedValue; + } + + if (nonNullableType == typeof(DateTime)) + { + DateTime convertedValue = DateTime.Parse(stringValue, cultureInfo, DateTimeStyles.RoundtripKind); + return isNullableTypeRequested ? (DateTime?)convertedValue : convertedValue; + } + + if (nonNullableType == typeof(DateTimeOffset)) + { + DateTimeOffset convertedValue = DateTimeOffset.Parse(stringValue, cultureInfo, DateTimeStyles.RoundtripKind); + return isNullableTypeRequested ? (DateTimeOffset?)convertedValue : convertedValue; + } + + if (nonNullableType == typeof(TimeSpan)) + { + TimeSpan convertedValue = TimeSpan.Parse(stringValue, cultureInfo); + return isNullableTypeRequested ? (TimeSpan?)convertedValue : convertedValue; + } + + if (nonNullableType == typeof(DateOnly)) + { + DateOnly convertedValue = DateOnly.Parse(stringValue, cultureInfo); + return isNullableTypeRequested ? (DateOnly?)convertedValue : convertedValue; + } + + if (nonNullableType == typeof(TimeOnly)) + { + TimeOnly convertedValue = TimeOnly.Parse(stringValue, cultureInfo); + return isNullableTypeRequested ? (TimeOnly?)convertedValue : convertedValue; + } + + if (nonNullableType.IsEnum) + { + object convertedValue = Enum.Parse(nonNullableType, stringValue); + + // https://bradwilson.typepad.com/blog/2008/07/creating-nullab.html + return convertedValue; + } + + // https://bradwilson.typepad.com/blog/2008/07/creating-nullab.html + return Convert.ChangeType(stringValue, nonNullableType, cultureInfo); + } + catch (Exception exception) when (exception is FormatException or OverflowException or InvalidCastException or ArgumentException) + { + string runtimeTypeName = runtimeType.GetFriendlyTypeName(); + string targetTypeName = type.GetFriendlyTypeName(); + + throw new FormatException($"Failed to convert '{value}' of type '{runtimeTypeName}' to type '{targetTypeName}'.", exception); + } + } + + /// + /// Indicates whether the specified type is a nullable value type or a reference type. + /// + public static bool CanContainNull(Type type) + { + ArgumentNullException.ThrowIfNull(type); + + return !type.IsValueType || Nullable.GetUnderlyingType(type) != null; + } + + /// + /// Gets the default value for the specified type. + /// + /// + /// The default value, or null for nullable value types and reference types. + /// + public static object? GetDefaultValue(Type type) + { + ArgumentNullException.ThrowIfNull(type); + + return type.IsValueType ? DefaultTypeCache.GetOrAdd(type, Activator.CreateInstance) : null; + } +} diff --git a/src/JsonApiDotNetCore.Annotations/TypeExtensions.cs b/src/JsonApiDotNetCore.Annotations/TypeExtensions.cs index c28ea84332..89b8158554 100644 --- a/src/JsonApiDotNetCore.Annotations/TypeExtensions.cs +++ b/src/JsonApiDotNetCore.Annotations/TypeExtensions.cs @@ -15,7 +15,7 @@ public static bool IsOrImplementsInterface(this Type? source) /// private static bool IsOrImplementsInterface(this Type? source, Type interfaceType) { - ArgumentGuard.NotNull(interfaceType, nameof(interfaceType)); + ArgumentNullException.ThrowIfNull(interfaceType); if (source == null) { @@ -41,14 +41,14 @@ private static bool AreTypesEqual(Type left, Type right, bool isLeftGeneric) /// public static string GetFriendlyTypeName(this Type type) { - ArgumentGuard.NotNull(type, nameof(type)); + ArgumentNullException.ThrowIfNull(type); // Based on https://stackoverflow.com/questions/2581642/how-do-i-get-the-type-name-of-a-generic-type-argument. if (type.IsGenericType) { string typeArguments = type.GetGenericArguments().Select(GetFriendlyTypeName).Aggregate((firstType, secondType) => $"{firstType}, {secondType}"); - return $"{type.Name[..type.Name.IndexOf("`", StringComparison.Ordinal)]}<{typeArguments}>"; + return $"{type.Name[..type.Name.IndexOf('`')]}<{typeArguments}>"; } return type.Name; diff --git a/src/JsonApiDotNetCore.OpenApi.Client.Kiota/Build/JsonApiDotNetCore.OpenApi.Client.Kiota.props b/src/JsonApiDotNetCore.OpenApi.Client.Kiota/Build/JsonApiDotNetCore.OpenApi.Client.Kiota.props new file mode 100644 index 0000000000..10a3fc585b --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Client.Kiota/Build/JsonApiDotNetCore.OpenApi.Client.Kiota.props @@ -0,0 +1,49 @@ + + + + + --backing-store --exclude-backward-compatible --clean-output --clear-cache --log-level Warning --disable-validation-rules KnownAndNotSupportedFormats,InconsistentTypeFormatPair + + + true + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/JsonApiDotNetCore.OpenApi.Client.Kiota/Build/JsonApiDotNetCore.OpenApi.Client.Kiota.targets b/src/JsonApiDotNetCore.OpenApi.Client.Kiota/Build/JsonApiDotNetCore.OpenApi.Client.Kiota.targets new file mode 100644 index 0000000000..44586a10ae --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Client.Kiota/Build/JsonApiDotNetCore.OpenApi.Client.Kiota.targets @@ -0,0 +1,189 @@ + + + + + + + + + (?:\r\n|\n|\r)(#pragma|using)", RegexOptions.Singleline | RegexOptions.Compiled); + private static readonly Regex NullableRegex = new(@"(?s)#if NETSTANDARD2_1_OR_GREATER .*?(?:\r\n|\n|\r)#nullable enable(?:\r\n|\n|\r)(?.*?)(?:\r\n|\n|\r)#nullable restore(?:\r\n|\n|\r)#else(?:\r\n|\n|\r)(?.*?)(?:\r\n|\n|\r)#endif", RegexOptions.Singleline | RegexOptions.Compiled); + private static readonly Regex LineBreaksRegex = new(@"}(?:\r\n|\n|\r)(?[ ]+/// )", RegexOptions.Singleline | RegexOptions.Compiled); + + public string StartDirectory { get; set; } + + public override bool Execute() + { + string absoluteStartDirectory = Path.GetFullPath(StartDirectory); + Log.LogMessage(MessageImportance.High, $"Patching kiota output files in {absoluteStartDirectory}"); + + foreach (string path in Directory.GetFiles(absoluteStartDirectory, "*.cs", SearchOption.AllDirectories)) + { + string content = File.ReadAllText(path); + content = HeaderRegex.Replace(content, $"// {Environment.NewLine}#nullable enable{Environment.NewLine}#pragma warning disable CS8625{Environment.NewLine}$1"); + content = NullableRegex.Replace(content, "$1"); + content = LineBreaksRegex.Replace(content, $"}}{Environment.NewLine}{Environment.NewLine}$1"); + + File.WriteAllText(path, content); + Log.LogMessage(MessageImportance.Normal, $"Patched file: {path}"); + } + + return true; + } + } + ]]> + + + + + + + + + + + + + + %(Arguments) --openapi %(Identity) + + + %(Arguments) --language %(Language) + + + %(Arguments) --language csharp + + + %(Arguments) --class-name %(ClassName) + + + %(Arguments) --namespace-name %(NamespaceName) + + + %(Arguments) --output %(OutputPath) + <_NonEmptyOutputPath>%(OutputPath) + + + <_NonEmptyOutputPath>./output + + + %(Arguments) --log-level %(LogLevel) + + + %(Arguments) --backing-store + + + %(Arguments) --exclude-backward-compatible + + + %(Arguments) --additional-data %(AdditionalData) + + + %(Arguments) --serializer %(Serializer) + + + %(Arguments) --deserializer %(Deserializer) + + + %(Arguments) --clean-output + + + %(Arguments) --clear-cache + + + %(Arguments) --structured-mime-types %(MimeTypes) + + + %(Arguments) --include-path %(IncludePath) + + + %(Arguments) --exclude-path %(ExcludePath) + + + %(Arguments) --disable-validation-rules %(DisableValidationRules) + + + %(Arguments) --disable-ssl-validation + + + %(Arguments) --type-access-modifier %(TypeAccessModifier) + + + %(Arguments) %(ExtraArguments) + + + + + + + + <_WildcardGroup Include="%2A%2A/%2A.cs"> + %(KiotaReference._NonEmptyOutputPath) + + + + + + + + + <_RelativeExcludePathGroup Include="@(_FilesToExcludeGroup)" Condition="'@(_FilesToExcludeGroup)' != ''"> + + $([MSBuild]::MakeRelative($(MSBuildProjectDirectory), %(_FilesToExcludeGroup.FullPath))) + + + + + + + + + + + + <_WildcardGroup Include="%2A%2A/%2A.cs"> + %(KiotaReference._NonEmptyOutputPath) + + + + + + + + + <_RelativeIncludePathGroup Include="@(_FilesToIncludeGroup)"> + + $([MSBuild]::MakeRelative($(MSBuildProjectDirectory), %(_FilesToIncludeGroup.FullPath))) + + + + + + + + + + + + <_KiotaCommand Condition="'$(KiotaAutoRestoreTools)' == 'true'">dotnet kiota generate + <_KiotaCommand Condition="'$(KiotaAutoRestoreTools)' != 'true'">kiota generate + + + + + + + + diff --git a/src/JsonApiDotNetCore.OpenApi.Client.Kiota/JsonApiDotNetCore.OpenApi.Client.Kiota.csproj b/src/JsonApiDotNetCore.OpenApi.Client.Kiota/JsonApiDotNetCore.OpenApi.Client.Kiota.csproj new file mode 100644 index 0000000000..640b949477 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Client.Kiota/JsonApiDotNetCore.OpenApi.Client.Kiota.csproj @@ -0,0 +1,42 @@ + + + net8.0 + true + true + false + $(NoWarn);NU5110;NU5111 + + + + + + $(VersionPrefix)-preview.$(OpenApiPreviewNumber) + jsonapidotnetcore;jsonapi;json:api;dotnet;asp.net;rest;web-api;openapi;swagger;client;kiota + Provides OpenAPI support for JSON:API generated clients using Kiota. + json-api-dotnet + https://www.jsonapi.net/ + MIT + false + See https://github.com/json-api-dotnet/JsonApiDotNetCore/releases. + package-icon.png + PackageReadme.md + true + embedded + + + + + + + + + + + + + + + + + + diff --git a/src/JsonApiDotNetCore.OpenApi.Client.Kiota/SetQueryStringHttpMessageHandler.cs b/src/JsonApiDotNetCore.OpenApi.Client.Kiota/SetQueryStringHttpMessageHandler.cs new file mode 100644 index 0000000000..fd088fb956 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Client.Kiota/SetQueryStringHttpMessageHandler.cs @@ -0,0 +1,57 @@ +using JetBrains.Annotations; +using Microsoft.AspNetCore.WebUtilities; + +namespace JsonApiDotNetCore.OpenApi.Client.Kiota; + +/// +/// Enables setting the HTTP query string. Workaround for https://github.com/microsoft/kiota/issues/3800. +/// +[PublicAPI] +public sealed class SetQueryStringHttpMessageHandler : DelegatingHandler +{ + private IDictionary? _queryString; + + public IDisposable CreateScope(IDictionary queryString) + { + ArgumentNullException.ThrowIfNull(queryString); + + return new QueryStringScope(this, queryString); + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + if (_queryString is { Count: > 0 } && request.RequestUri != null) + { + request.RequestUri = new Uri(QueryHelpers.AddQueryString(request.RequestUri.ToString(), _queryString)); + } + + return base.SendAsync(request, cancellationToken); + } + + private sealed class QueryStringScope : IDisposable + { + private readonly SetQueryStringHttpMessageHandler _owner; + private readonly IDictionary? _backupQueryString; + + public QueryStringScope(SetQueryStringHttpMessageHandler owner, IDictionary queryString) + { + _owner = owner; + _backupQueryString = owner._queryString; + + owner._queryString = SetEmptyStringForNullValues(queryString); + } + + private static Dictionary SetEmptyStringForNullValues(IDictionary queryString) + { + // QueryHelpers.AddQueryString ignores null values, so replace them with empty strings to get them sent. + return queryString.ToDictionary(pair => pair.Key, pair => pair.Value ?? (string?)string.Empty); + } + + public void Dispose() + { + _owner._queryString = _backupQueryString; + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Client.NSwag/ApiException.cs b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/ApiException.cs new file mode 100644 index 0000000000..a86118c875 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/ApiException.cs @@ -0,0 +1,38 @@ +using JetBrains.Annotations; + +// We cannot rely on generating ApiException as soon as we are generating multiple clients, see https://github.com/RicoSuter/NSwag/issues/2839#issuecomment-776647377. +// Instead, we configure NSwag to point to the exception below in the generated code. + +namespace JsonApiDotNetCore.OpenApi.Client.NSwag; + +/// +/// Replacement for the auto-generated +/// +/// ApiException +/// +/// class from NSwag. +/// +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public class ApiException(string message, int statusCode, string? response, IReadOnlyDictionary> headers, Exception? innerException) + : Exception($"HTTP {statusCode}: {message}", innerException) +{ + public int StatusCode { get; } = statusCode; + public virtual string? Response { get; } = string.IsNullOrEmpty(response) ? null : response; + public IReadOnlyDictionary> Headers { get; } = headers; +} + +/// +/// Replacement for the auto-generated +/// +/// ApiException<TResult> +/// +/// class from NSwag. +/// +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class ApiException( + string message, int statusCode, string? response, IReadOnlyDictionary> headers, TResult result, Exception? innerException) + : ApiException(message, statusCode, response, headers, innerException) +{ + public TResult Result { get; } = result; + public override string Response => $"The response body is unavailable. Use the {nameof(Result)} property instead."; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Client.NSwag/ApiResponse.cs b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/ApiResponse.cs new file mode 100644 index 0000000000..7d3d7c2a52 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/ApiResponse.cs @@ -0,0 +1,93 @@ +using System.Net; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.OpenApi.Client.NSwag; + +/// +/// Replacement for the auto-generated +/// +/// SwaggerResponse +/// +/// class from NSwag. +/// +[PublicAPI] +public class ApiResponse(int statusCode, IReadOnlyDictionary> headers) +{ + public int StatusCode { get; private set; } = statusCode; + public IReadOnlyDictionary> Headers { get; private set; } = headers; + + public static async Task TranslateAsync(Func> operation) + where TResponse : class + { + ArgumentNullException.ThrowIfNull(operation); + + try + { + return await operation().ConfigureAwait(false); + } + catch (ApiException exception) when (exception.StatusCode is (int)HttpStatusCode.NoContent or (int)HttpStatusCode.NotModified) + { + // Workaround for https://github.com/RicoSuter/NSwag/issues/2499 + return null; + } + } + + public static async Task TranslateAsync(Func operation) + { + ArgumentNullException.ThrowIfNull(operation); + + try + { + await operation().ConfigureAwait(false); + } + catch (ApiException exception) when (exception.StatusCode is (int)HttpStatusCode.NoContent or (int)HttpStatusCode.NotModified) + { + // Workaround for https://github.com/RicoSuter/NSwag/issues/2499 + } + } + + public static async Task> TranslateAsync(Func>> operation) + where TResult : class + { + ArgumentNullException.ThrowIfNull(operation); + + try + { + return (await operation().ConfigureAwait(false))!; + } + catch (ApiException exception) when (exception.StatusCode is (int)HttpStatusCode.NoContent or (int)HttpStatusCode.NotModified) + { + // Workaround for https://github.com/RicoSuter/NSwag/issues/2499 + return new ApiResponse(exception.StatusCode, exception.Headers, null); + } + } + + public static async Task TranslateAsync(Func> operation) + { + ArgumentNullException.ThrowIfNull(operation); + + try + { + return await operation().ConfigureAwait(false); + } + catch (ApiException exception) when (exception.StatusCode is (int)HttpStatusCode.NoContent or (int)HttpStatusCode.NotModified) + { + // Workaround for https://github.com/RicoSuter/NSwag/issues/2499 + return new ApiResponse(exception.StatusCode, exception.Headers); + } + } +} + +/// +/// Replacement for the auto-generated +/// +/// SwaggerResponse<TResult> +/// +/// class from NSwag. +/// +[PublicAPI] +public class ApiResponse(int statusCode, IReadOnlyDictionary> headers, TResult result) + : ApiResponse(statusCode, headers) +{ + public TResult Result { get; private set; } = result; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Client.NSwag/BlockedJsonInheritanceConverter.cs b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/BlockedJsonInheritanceConverter.cs new file mode 100644 index 0000000000..602bacce63 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/BlockedJsonInheritanceConverter.cs @@ -0,0 +1,56 @@ +using JetBrains.Annotations; +using Newtonsoft.Json; + +namespace JsonApiDotNetCore.OpenApi.Client.NSwag; + +// Referenced from liquid template, to ensure the built-in JsonInheritanceConverter from NSwag is never used. +/// +/// Exists to block usage of the default +/// +/// JsonInheritanceConverter +/// +/// from NSwag, which is incompatible with JSON:API. +/// +[PublicAPI] +public abstract class BlockedJsonInheritanceConverter : JsonConverter +{ + private const string DefaultDiscriminatorName = "discriminator"; + + public string DiscriminatorName { get; } + + public override bool CanWrite => true; + public override bool CanRead => true; + + protected BlockedJsonInheritanceConverter() + : this(DefaultDiscriminatorName) + { + } + + protected BlockedJsonInheritanceConverter(string discriminatorName) + { + ArgumentException.ThrowIfNullOrEmpty(discriminatorName); + + DiscriminatorName = discriminatorName; + } + + public override bool CanConvert(Type objectType) + { + return true; + } + + /// + /// Always throws an . + /// + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + throw new InvalidOperationException("JsonInheritanceConverter is incompatible with JSON:API and must not be used."); + } + + /// + /// Always throws an . + /// + public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + { + throw new InvalidOperationException("JsonInheritanceConverter is incompatible with JSON:API and must not be used."); + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Client.NSwag/Build/JsonApiDotNetCore.OpenApi.Client.NSwag.props b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/Build/JsonApiDotNetCore.OpenApi.Client.NSwag.props new file mode 100644 index 0000000000..cc65a6ac4a --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/Build/JsonApiDotNetCore.OpenApi.Client.NSwag.props @@ -0,0 +1,15 @@ + + + NSwagCSharp + false + false + ApiResponse + JsonApiDotNetCore.OpenApi.Client.NSwag + true + true + true + JsonApiDotNetCore.OpenApi.Client.NSwag.JsonApiClient + Prism + $(MSBuildThisFileDirectory)../Templates + + diff --git a/src/JsonApiDotNetCore.OpenApi.Client.NSwag/JsonApiClient.cs b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/JsonApiClient.cs new file mode 100644 index 0000000000..14cf54825d --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/JsonApiClient.cs @@ -0,0 +1,458 @@ +using System.ComponentModel; +using System.Reflection; +using JetBrains.Annotations; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; + +namespace JsonApiDotNetCore.OpenApi.Client.NSwag; + +/// +/// Base class to inherit auto-generated NSwag OpenAPI clients from. Provides support for partial POST/PATCH in JSON:API requests, optionally combined +/// with OpenAPI inheritance. +/// +[PublicAPI] +public abstract class JsonApiClient +{ + private const string GeneratedJsonInheritanceConverterName = "JsonInheritanceConverter"; + private static readonly DefaultContractResolver UnmodifiedContractResolver = new(); + + private readonly Dictionary> _propertyStore = []; + + /// + /// Whether to automatically clear tracked properties after sending a request. Default value: true. Set to false to reuse tracked + /// properties for multiple requests and call after the last request to clean up. + /// + public bool AutoClearTracked { get; set; } = true; + + internal void Track(T container) + where T : INotifyPropertyChanged, new() + { + container.PropertyChanged += ContainerOnPropertyChanged; + + MarkAsTracked(container); + } + + private void ContainerOnPropertyChanged(object? sender, PropertyChangedEventArgs args) + { + if (sender is INotifyPropertyChanged container && args.PropertyName != null) + { + MarkAsTracked(container, args.PropertyName); + } + } + + /// + /// Marks the specified properties on an object instance as tracked. Use this when unable to use inline initializer syntax for tracking. + /// + /// + /// The object instance whose properties to mark as tracked. + /// + /// + /// The names of the properties to mark as tracked. Properties in this list are always included. Any other property is only included if its value differs + /// from the property type's default value. + /// + public void MarkAsTracked(INotifyPropertyChanged container, params string[] propertyNames) + { + ArgumentNullException.ThrowIfNull(container); + ArgumentNullException.ThrowIfNull(propertyNames); + + if (!_propertyStore.TryGetValue(container, out ISet? properties)) + { + properties = new HashSet(); + _propertyStore[container] = properties; + } + + foreach (string propertyName in propertyNames) + { + properties.Add(propertyName); + } + } + + /// + /// Clears all tracked properties. Call this after sending multiple requests when is set to false. + /// + public void ClearAllTracked() + { + foreach (INotifyPropertyChanged container in _propertyStore.Keys) + { + container.PropertyChanged -= ContainerOnPropertyChanged; + } + + _propertyStore.Clear(); + } + + private void RemoveContainer(INotifyPropertyChanged container) + { + container.PropertyChanged -= ContainerOnPropertyChanged; + _propertyStore.Remove(container); + } + + /// + /// Initial setup. Call this from the Initialize partial method in the auto-generated NSwag client. + /// + /// + /// The to configure. + /// + /// + /// CAUTION: Calling this method makes the serializer stateful, which removes thread-safety of the owning auto-generated NSwag client. As a result, the + /// client MUST NOT be shared. So don't use a static instance, and don't register as a singleton in the service container. Also, do not execute parallel + /// requests on the same NSwag client instance. Executing multiple sequential requests on the same generated client instance is fine. + /// + protected void SetSerializerSettingsForJsonApi(JsonSerializerSettings serializerSettings) + { + ArgumentNullException.ThrowIfNull(serializerSettings); + + serializerSettings.ContractResolver = new InsertDiscriminatorPropertyContractResolver(); + serializerSettings.Converters.Insert(0, new PropertyTrackingInheritanceConverter(this)); + } + + private static string? GetDiscriminatorName(Type objectType) + { + JsonContract contract = UnmodifiedContractResolver.ResolveContract(objectType); + + if (contract.Converter != null && contract.Converter.GetType().Name == GeneratedJsonInheritanceConverterName) + { + var inheritanceConverter = (BlockedJsonInheritanceConverter)contract.Converter; + return inheritanceConverter.DiscriminatorName; + } + + return null; + } + + /// + /// Replacement for the writing part of client-generated JsonInheritanceConverter that doesn't block other converters and preserves the JSON path on + /// error. + /// + private class InsertDiscriminatorPropertyContractResolver : DefaultContractResolver + { + protected override JsonObjectContract CreateObjectContract(Type objectType) + { + // NSwag adds [JsonConverter(typeof(JsonInheritanceConverter), "type")] on types to write the discriminator. + // This annotation has higher precedence over converters in the serializer settings, which is why ours normally won't execute. + // Once we tell Newtonsoft to ignore JsonInheritanceConverter, our converter can kick in. + + JsonObjectContract contract = base.CreateObjectContract(objectType); + + if (contract.Converter != null && contract.Converter.GetType().Name == GeneratedJsonInheritanceConverterName) + { + contract.Converter = null; + } + + return contract; + } + + protected override IList CreateProperties(Type type, MemberSerialization memberSerialization) + { + IList properties = base.CreateProperties(type, memberSerialization); + + string? discriminatorName = GetDiscriminatorName(type); + + if (discriminatorName != null) + { + JsonProperty discriminatorProperty = CreateDiscriminatorProperty(discriminatorName, type); + properties.Insert(0, discriminatorProperty); + } + + return properties; + } + + private static JsonProperty CreateDiscriminatorProperty(string discriminatorName, Type declaringType) + { + return new JsonProperty + { + PropertyName = discriminatorName, + PropertyType = typeof(string), + DeclaringType = declaringType, + ValueProvider = new DiscriminatorValueProvider(), + Readable = true, + Writable = true + }; + } + + private sealed class DiscriminatorValueProvider : IValueProvider + { + public object? GetValue(object target) + { + Type type = target.GetType(); + + foreach (Attribute attribute in type.GetCustomAttributes(true)) + { + var shim = JsonInheritanceAttributeShim.TryCreate(attribute); + + if (shim != null && shim.Type == type) + { + return shim.Key; + } + } + + return null; + } + + public void SetValue(object target, object? value) + { + // Nothing to do, NSwag doesn't generate a property for the discriminator. + } + } + } + + /// + /// Provides support for writing partial POST/PATCH in JSON:API requests via tracked properties. Provides reading of discriminator for inheritance. + /// + private sealed class PropertyTrackingInheritanceConverter : JsonConverter + { + [ThreadStatic] + private static bool _isWriting; + + [ThreadStatic] + private static bool _isReading; + + private readonly JsonApiClient _apiClient; + + public override bool CanRead + { + get + { + if (_isReading) + { + // Prevent infinite recursion, but auto-reset so we'll participate in nested objects. + _isReading = false; + return false; + } + + return true; + } + } + + public override bool CanWrite + { + get + { + if (_isWriting) + { + // Prevent infinite recursion, but auto-reset so we'll participate in nested objects. + _isWriting = false; + return false; + } + + return true; + } + } + + public PropertyTrackingInheritanceConverter(JsonApiClient apiClient) + { + ArgumentNullException.ThrowIfNull(apiClient); + + _apiClient = apiClient; + } + + public override bool CanConvert(Type objectType) + { + // Because this is called BEFORE CanRead/CanWrite, respond to both tracking and inheritance. + // We don't actually write for inheritance, so bail out later if that's the case. + + if (_apiClient._propertyStore.Keys.Any(containingType => containingType.GetType() == objectType)) + { + return true; + } + + var converterAttribute = objectType.GetCustomAttribute(true); + return converterAttribute is { ConverterType.Name: GeneratedJsonInheritanceConverterName }; + } + + public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + { + _isReading = true; + + try + { + JToken token = JToken.ReadFrom(reader); + string? discriminatorValue = GetDiscriminatorValue(objectType, token); + + Type resolvedType = ResolveTypeFromDiscriminatorValue(objectType, discriminatorValue); + return token.ToObject(resolvedType, serializer); + } + finally + { + _isReading = false; + } + } + + private static string? GetDiscriminatorValue(Type objectType, JToken token) + { + var jsonConverterAttribute = objectType.GetCustomAttribute(true)!; + + if (jsonConverterAttribute.ConverterParameters is not [string]) + { + throw new JsonException($"Expected single 'type' parameter for JsonInheritanceConverter usage on type '{objectType}'."); + } + + string discriminatorName = (string)jsonConverterAttribute.ConverterParameters[0]; + return token.Children().FirstOrDefault(property => property.Name == discriminatorName)?.Value.ToString(); + } + + private static Type ResolveTypeFromDiscriminatorValue(Type objectType, string? discriminatorValue) + { + if (discriminatorValue != null) + { + foreach (Attribute attribute in objectType.GetCustomAttributes(true)) + { + var shim = JsonInheritanceAttributeShim.TryCreate(attribute); + + if (shim != null && shim.Key == discriminatorValue) + { + return shim.Type; + } + } + } + + return objectType; + } + + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + _isWriting = true; + + try + { + if (value is INotifyPropertyChanged container && _apiClient._propertyStore.TryGetValue(container, out ISet? properties)) + { + // Because we're overwriting NullValueHandling/DefaultValueHandling, we miss out on some validations that Newtonsoft would otherwise run. + AssertRequiredTrackedPropertiesHaveNoDefaultValue(container, properties, writer.Path); + + IContractResolver backupContractResolver = serializer.ContractResolver; + + try + { + // Caution: Swapping the contract resolver is not safe for concurrent usage, yet it needs to know the tracked instance. + serializer.ContractResolver = new PropertyTrackingContractResolver(container, properties); + serializer.Serialize(writer, value); + + if (_apiClient.AutoClearTracked) + { + _apiClient.RemoveContainer(container); + } + } + finally + { + serializer.ContractResolver = backupContractResolver; + } + } + else + { + // We get here when the type is tracked, but not this instance. Or when writing for inheritance. + serializer.Serialize(writer, value); + } + } + finally + { + _isWriting = false; + } + } + + private static void AssertRequiredTrackedPropertiesHaveNoDefaultValue(object container, ISet properties, string jsonPath) + { + foreach (PropertyInfo propertyInfo in container.GetType().GetProperties()) + { + bool isTracked = properties.Contains(propertyInfo.Name); + + if (!isTracked) + { + AssertPropertyHasNonDefaultValueIfRequired(container, propertyInfo, jsonPath); + } + } + } + + private static void AssertPropertyHasNonDefaultValueIfRequired(object attributesObject, PropertyInfo propertyInfo, string jsonPath) + { + var jsonProperty = propertyInfo.GetCustomAttribute(); + + if (jsonProperty is { Required: Required.Always or Required.AllowNull }) + { + if (PropertyHasDefaultValue(propertyInfo, attributesObject)) + { + throw new JsonSerializationException( + $"Cannot write a default value for property '{jsonProperty.PropertyName}'. Property requires a non-default value. Path '{jsonPath}'.", + jsonPath, 0, 0, null); + } + } + } + + private static bool PropertyHasDefaultValue(PropertyInfo propertyInfo, object instance) + { + object? propertyValue = propertyInfo.GetValue(instance); + object? defaultValue = GetDefaultValue(propertyInfo.PropertyType); + + return EqualityComparer.Default.Equals(propertyValue, defaultValue); + } + + private static object? GetDefaultValue(Type type) + { + return type.IsValueType ? Activator.CreateInstance(type) : null; + } + } + + /// + /// Overrules the and annotations on generated properties for tracked object + /// instances to support JSON:API partial POST/PATCH. + /// + private sealed class PropertyTrackingContractResolver : InsertDiscriminatorPropertyContractResolver + { + private readonly INotifyPropertyChanged _container; + private readonly ISet _properties; + + public PropertyTrackingContractResolver(INotifyPropertyChanged container, ISet properties) + { + ArgumentNullException.ThrowIfNull(container); + ArgumentNullException.ThrowIfNull(properties); + + _container = container; + _properties = properties; + } + + protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) + { + JsonProperty jsonProperty = base.CreateProperty(member, memberSerialization); + + if (jsonProperty.DeclaringType == _container.GetType()) + { + if (_properties.Contains(jsonProperty.UnderlyingName!)) + { + jsonProperty.NullValueHandling = NullValueHandling.Include; + jsonProperty.DefaultValueHandling = DefaultValueHandling.Include; + } + else + { + jsonProperty.NullValueHandling = NullValueHandling.Ignore; + jsonProperty.DefaultValueHandling = DefaultValueHandling.Ignore; + } + } + + return jsonProperty; + } + } + + private sealed class JsonInheritanceAttributeShim + { + private readonly Attribute _instance; + private readonly PropertyInfo _keyProperty; + private readonly PropertyInfo _typeProperty; + + public string Key => (string)_keyProperty.GetValue(_instance)!; + public Type Type => (Type)_typeProperty.GetValue(_instance)!; + + private JsonInheritanceAttributeShim(Attribute instance, Type type) + { + _instance = instance; + _keyProperty = type.GetProperty("Key") ?? throw new ArgumentException("Key property not found.", nameof(instance)); + _typeProperty = type.GetProperty("Type") ?? throw new ArgumentException("Type property not found.", nameof(instance)); + } + + public static JsonInheritanceAttributeShim? TryCreate(Attribute attribute) + { + ArgumentNullException.ThrowIfNull(attribute); + + Type type = attribute.GetType(); + return type.Name == "JsonInheritanceAttribute" ? new JsonInheritanceAttributeShim(attribute, type) : null; + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Client.NSwag/JsonApiDotNetCore.OpenApi.Client.NSwag.csproj b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/JsonApiDotNetCore.OpenApi.Client.NSwag.csproj new file mode 100644 index 0000000000..20e2306730 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/JsonApiDotNetCore.OpenApi.Client.NSwag.csproj @@ -0,0 +1,37 @@ + + + net8.0 + true + true + false + + + + + + $(VersionPrefix)-preview.$(OpenApiPreviewNumber) + jsonapidotnetcore;jsonapi;json:api;dotnet;asp.net;rest;web-api;openapi;swagger;client;nswag + Provides OpenAPI support for JSON:API generated clients using NSwag. + json-api-dotnet + https://www.jsonapi.net/ + MIT + false + See https://github.com/json-api-dotnet/JsonApiDotNetCore/releases. + package-icon.png + PackageReadme.md + true + embedded + + + + + + + + + + + + + + diff --git a/src/JsonApiDotNetCore.OpenApi.Client.NSwag/NotifyPropertySet.cs b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/NotifyPropertySet.cs new file mode 100644 index 0000000000..111b894879 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/NotifyPropertySet.cs @@ -0,0 +1,73 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; +using JetBrains.Annotations; + +// Adapted from https://github.com/PrismLibrary/Prism/blob/9.0.537/src/Prism.Core/Mvvm/BindableBase.cs for JsonApiDotNetCore. + +#pragma warning disable AV1553 // Do not use optional parameters with default value null for strings, collections or tasks +#pragma warning disable AV1554 // Method contains optional parameter in type hierarchy +#pragma warning disable AV1562 // Do not declare a parameter as ref or out + +namespace JsonApiDotNetCore.OpenApi.Client.NSwag; + +/// +/// Implementation of that unconditionally raises the event when a property is +/// assigned. Exists to support JSON:API partial POST/PATCH. +/// +[PublicAPI] +public abstract class NotifyPropertySet : INotifyPropertyChanged +{ + /// + /// Occurs when a property is set. + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// + /// Sets the property and notifies listeners. + /// + /// + /// Type of the property. + /// + /// + /// Reference to a property with both getter and setter. + /// + /// + /// Desired value for the property. + /// + /// + /// Name of the property used to notify listeners. This value is optional and can be provided automatically when invoked from compilers that support + /// CallerMemberName. + /// + /// + /// Always true. + /// + protected virtual bool SetProperty(ref T storage, T value, [CallerMemberName] string? propertyName = null) + { + storage = value; + RaisePropertyChanged(propertyName); + return true; + } + + /// + /// Raises this object's PropertyChanged event. + /// + /// + /// Name of the property used to notify listeners. This value is optional and can be provided automatically when invoked from compilers that support + /// . + /// + protected void RaisePropertyChanged([CallerMemberName] string? propertyName = null) + { + OnPropertyChanged(new PropertyChangedEventArgs(propertyName)); + } + + /// + /// Raises this object's PropertyChanged event. + /// + /// + /// The . + /// + protected virtual void OnPropertyChanged(PropertyChangedEventArgs args) + { + PropertyChanged?.Invoke(this, args); + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Client.NSwag/Properties/AssemblyInfo.cs b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..1ac53bb335 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("OpenApiNSwagClientTests")] diff --git a/src/JsonApiDotNetCore.OpenApi.Client.NSwag/Templates/Class.Inheritance.liquid b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/Templates/Class.Inheritance.liquid new file mode 100644 index 0000000000..1ddffc4af0 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/Templates/Class.Inheritance.liquid @@ -0,0 +1,14 @@ +{% comment %} + + Adapted from https://github.com/RicoSuter/NJsonSchema/blob/v11.1.0/src/NJsonSchema.CodeGeneration.CSharp/Templates/Class.Inheritance.liquid + for JsonApiDotNetCore, to intercept when properties are set. This is needed to support partial POST/PATCH to distinguish between sending + a property with its default value versus omitting the property. + +{% endcomment %} +{%- if RenderInpc %} +{% if HasInheritance %} : {{ BaseClassName }}{% else %} : System.ComponentModel.INotifyPropertyChanged{% endif %} +{%- elsif RenderPrism %} +{% if HasInheritance %} : {{ BaseClassName }}{% else %} : JsonApiDotNetCore.OpenApi.Client.NSwag.NotifyPropertySet{% endif %} +{%- else %} +{% if HasInheritance %} : {{ BaseClassName }}{% endif %} +{%- endif %} diff --git a/src/JsonApiDotNetCore.OpenApi.Client.NSwag/Templates/Client.Class.Annotations.liquid b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/Templates/Client.Class.Annotations.liquid new file mode 100644 index 0000000000..bf791d968b --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/Templates/Client.Class.Annotations.liquid @@ -0,0 +1,14 @@ +{% comment %} + + Adapted from https://github.com/RicoSuter/NSwag/blob/v14.2.0/src/NSwag.CodeGeneration.CSharp/Templates/Client.Class.Annotations.liquid + for JsonApiDotNetCore, to initialize the JSON serializer for use with JSON:API. + +{% endcomment %} +partial class {{ Class }} +{ + partial void Initialize() + { + _instanceSettings = new Newtonsoft.Json.JsonSerializerSettings(_settings.Value); + SetSerializerSettingsForJsonApi(_instanceSettings); + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Client.NSwag/Templates/JsonInheritanceConverter.liquid b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/Templates/JsonInheritanceConverter.liquid new file mode 100644 index 0000000000..e524f3e1f7 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/Templates/JsonInheritanceConverter.liquid @@ -0,0 +1,19 @@ +{% comment %} + + Adapted from https://github.com/RicoSuter/NJsonSchema/blob/v11.1.0/src/NJsonSchema.CodeGeneration.CSharp/Templates/JsonInheritanceConverter.liquid + for JsonApiDotNetCore, to provide alternate OpenAPI inheritance implementation that enables the use of third-party converters. + +{% endcomment %} +[System.CodeDom.Compiler.GeneratedCode("NJsonSchema", "{{ ToolchainVersion }}")] +public class JsonInheritanceConverter : JsonApiDotNetCore.OpenApi.Client.NSwag.BlockedJsonInheritanceConverter +{ + public JsonInheritanceConverter() + : base() + { + } + + public JsonInheritanceConverter(string discriminatorName) + : base(discriminatorName) + { + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Client.NSwag/TrackChangesFor.cs b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/TrackChangesFor.cs new file mode 100644 index 0000000000..278b10ceac --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Client.NSwag/TrackChangesFor.cs @@ -0,0 +1,23 @@ +using System.ComponentModel; + +namespace JsonApiDotNetCore.OpenApi.Client.NSwag; + +/// +/// Tracks assignment of property values, to support JSON:API partial POST/PATCH. +/// +/// +/// The type whose property assignments to track. +/// +public sealed class TrackChangesFor + where T : INotifyPropertyChanged, new() +{ + public T Initializer { get; } + + public TrackChangesFor(JsonApiClient apiClient) + { + ArgumentNullException.ThrowIfNull(apiClient); + + Initializer = new T(); + apiClient.Track(Initializer); + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ActionDescriptorExtensions.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ActionDescriptorExtensions.cs new file mode 100644 index 0000000000..1892aca576 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ActionDescriptorExtensions.cs @@ -0,0 +1,33 @@ +using System.Reflection; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle; + +internal static class ActionDescriptorExtensions +{ + public static MethodInfo GetActionMethod(this ActionDescriptor descriptor) + { + ArgumentNullException.ThrowIfNull(descriptor); + + return ((ControllerActionDescriptor)descriptor).MethodInfo; + } + + public static TFilterMetaData? GetFilterMetadata(this ActionDescriptor descriptor) + where TFilterMetaData : IFilterMetadata + { + ArgumentNullException.ThrowIfNull(descriptor); + + return descriptor.FilterDescriptors.Select(filterDescriptor => filterDescriptor.Filter).OfType().FirstOrDefault(); + } + + public static ControllerParameterDescriptor? GetBodyParameterDescriptor(this ActionDescriptor descriptor) + { + ArgumentNullException.ThrowIfNull(descriptor); + + return (ControllerParameterDescriptor?)descriptor.Parameters.FirstOrDefault(parameterDescriptor => + parameterDescriptor.BindingInfo?.BindingSource == BindingSource.Body); + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ConfigureMvcOptions.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ConfigureMvcOptions.cs new file mode 100644 index 0000000000..e1e04a2464 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ConfigureMvcOptions.cs @@ -0,0 +1,50 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle; + +internal sealed class ConfigureMvcOptions : IConfigureOptions +{ + private readonly IJsonApiRoutingConvention _jsonApiRoutingConvention; + private readonly OpenApiEndpointConvention _openApiEndpointConvention; + private readonly JsonApiRequestFormatMetadataProvider _jsonApiRequestFormatMetadataProvider; + private readonly IJsonApiOptions _jsonApiOptions; + + public ConfigureMvcOptions(IJsonApiRoutingConvention jsonApiRoutingConvention, OpenApiEndpointConvention openApiEndpointConvention, + JsonApiRequestFormatMetadataProvider jsonApiRequestFormatMetadataProvider, IJsonApiOptions jsonApiOptions) + { + ArgumentNullException.ThrowIfNull(jsonApiRoutingConvention); + ArgumentNullException.ThrowIfNull(openApiEndpointConvention); + ArgumentNullException.ThrowIfNull(jsonApiRequestFormatMetadataProvider); + ArgumentNullException.ThrowIfNull(jsonApiOptions); + + _jsonApiRoutingConvention = jsonApiRoutingConvention; + _openApiEndpointConvention = openApiEndpointConvention; + _jsonApiRequestFormatMetadataProvider = jsonApiRequestFormatMetadataProvider; + _jsonApiOptions = jsonApiOptions; + } + + public void Configure(MvcOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + AddSwashbuckleCliCompatibility(options); + + options.InputFormatters.Add(_jsonApiRequestFormatMetadataProvider); + options.Conventions.Add(_openApiEndpointConvention); + + ((JsonApiOptions)_jsonApiOptions).IncludeExtensions(OpenApiMediaTypeExtension.OpenApi, OpenApiMediaTypeExtension.RelaxedOpenApi); + } + + private void AddSwashbuckleCliCompatibility(MvcOptions options) + { + if (!options.Conventions.Any(convention => convention is IJsonApiRoutingConvention)) + { + // See https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/1957 for why this is needed. + options.Conventions.Insert(0, _jsonApiRoutingConvention); + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ConfigureSwaggerGenOptions.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ConfigureSwaggerGenOptions.cs new file mode 100644 index 0000000000..f3fb5198ca --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ConfigureSwaggerGenOptions.cs @@ -0,0 +1,152 @@ +using System.Reflection; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.AtomicOperations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.OpenApi.Swashbuckle.SwaggerComponents; +using JsonApiDotNetCore.Resources; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle; + +internal sealed class ConfigureSwaggerGenOptions : IConfigureOptions +{ + private static readonly Dictionary BaseToDerivedSchemaTypes = new() + { + [typeof(IdentifierInRequest)] = typeof(IdentifierInRequest<>), + [typeof(ResourceInCreateRequest)] = typeof(DataInCreateRequest<>), + [typeof(ResourceInUpdateRequest)] = typeof(DataInUpdateRequest<>), + [typeof(ResourceInResponse)] = typeof(DataInResponse<>) + }; + + private static readonly Type[] AtomicOperationDerivedSchemaTypes = + [ + typeof(CreateOperation<>), + typeof(UpdateOperation<>), + typeof(DeleteOperation<>), + typeof(UpdateToOneRelationshipOperation<>), + typeof(UpdateToManyRelationshipOperation<>), + typeof(AddToRelationshipOperation<>), + typeof(RemoveFromRelationshipOperation<>) + ]; + + private readonly OpenApiOperationIdSelector _operationIdSelector; + private readonly JsonApiSchemaIdSelector _schemaIdSelector; + private readonly IControllerResourceMapping _controllerResourceMapping; + private readonly IResourceGraph _resourceGraph; + + public ConfigureSwaggerGenOptions(OpenApiOperationIdSelector operationIdSelector, JsonApiSchemaIdSelector schemaIdSelector, + IControllerResourceMapping controllerResourceMapping, IResourceGraph resourceGraph) + { + ArgumentNullException.ThrowIfNull(operationIdSelector); + ArgumentNullException.ThrowIfNull(schemaIdSelector); + ArgumentNullException.ThrowIfNull(controllerResourceMapping); + ArgumentNullException.ThrowIfNull(resourceGraph); + + _operationIdSelector = operationIdSelector; + _schemaIdSelector = schemaIdSelector; + _controllerResourceMapping = controllerResourceMapping; + _resourceGraph = resourceGraph; + } + + public void Configure(SwaggerGenOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + options.SupportNonNullableReferenceTypes(); + options.UseAllOfToExtendReferenceSchemas(); + + options.UseAllOfForInheritance(); + options.SelectDiscriminatorNameUsing(_ => JsonApiPropertyName.Type); + options.SelectDiscriminatorValueUsing(clrType => _resourceGraph.GetResourceType(clrType).PublicName); + options.SelectSubTypesUsing(SelectDerivedTypes); + + options.TagActionsBy(description => GetOpenApiOperationTags(description, _controllerResourceMapping)); + options.CustomOperationIds(_operationIdSelector.GetOpenApiOperationId); + options.CustomSchemaIds(_schemaIdSelector.GetSchemaId); + + options.OperationFilter(); + options.DocumentFilter(); + options.DocumentFilter(); + options.DocumentFilter(); + options.DocumentFilter(); + options.DocumentFilter(); + } + + private List SelectDerivedTypes(Type baseType) + { + if (BaseToDerivedSchemaTypes.TryGetValue(baseType, out Type? schemaOpenType)) + { + return GetConstructedTypesFromResourceGraph(schemaOpenType); + } + + if (baseType == typeof(AtomicOperation)) + { + return GetConstructedTypesForAtomicOperation(); + } + + if (baseType.IsAssignableTo(typeof(IIdentifiable))) + { + ResourceType? resourceType = _resourceGraph.FindResourceType(baseType); + + if (resourceType != null && resourceType.IsPartOfTypeHierarchy()) + { + return GetResourceDerivedTypes(resourceType); + } + } + + return []; + } + + private List GetConstructedTypesFromResourceGraph(Type schemaOpenType) + { + List constructedTypes = []; + + foreach (ResourceType resourceType in _resourceGraph.GetResourceTypes()) + { + Type constructedType = schemaOpenType.MakeGenericType(resourceType.ClrType); + constructedTypes.Add(constructedType); + } + + return constructedTypes; + } + + private List GetConstructedTypesForAtomicOperation() + { + List derivedTypes = []; + + foreach (ResourceType resourceType in _resourceGraph.GetResourceTypes()) + { + derivedTypes.AddRange(AtomicOperationDerivedSchemaTypes.Select(openType => openType.MakeGenericType(resourceType.ClrType))); + } + + return derivedTypes; + } + + private static List GetResourceDerivedTypes(ResourceType baseType) + { + List clrTypes = []; + IncludeDerivedTypes(baseType, clrTypes); + return clrTypes; + } + + private static void IncludeDerivedTypes(ResourceType baseType, List clrTypes) + { + foreach (ResourceType derivedType in baseType.DirectlyDerivedTypes) + { + clrTypes.Add(derivedType.ClrType); + IncludeDerivedTypes(derivedType, clrTypes); + } + } + + private static List GetOpenApiOperationTags(ApiDescription description, IControllerResourceMapping controllerResourceMapping) + { + MethodInfo actionMethod = description.ActionDescriptor.GetActionMethod(); + ResourceType? resourceType = controllerResourceMapping.GetResourceTypeForController(actionMethod.ReflectedType); + + return resourceType == null ? ["operations"] : [resourceType.PublicName]; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ConsistencyGuard.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ConsistencyGuard.cs new file mode 100644 index 0000000000..a5ab4f69f5 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ConsistencyGuard.cs @@ -0,0 +1,18 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle; + +#pragma warning disable AV1008 // Class should not be static + +internal static class ConsistencyGuard +{ + [ExcludeFromCodeCoverage] + public static void ThrowIf([DoesNotReturnIf(true)] bool condition) + { + if (condition) + { + throw new UnreachableException(); + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/IJsonApiRequestAccessor.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/IJsonApiRequestAccessor.cs new file mode 100644 index 0000000000..ab9445b39e --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/IJsonApiRequestAccessor.cs @@ -0,0 +1,14 @@ +using JsonApiDotNetCore.Middleware; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle; + +/// +/// Provides access to the current , if one is available. +/// +internal interface IJsonApiRequestAccessor +{ + /// + /// Gets the current . Returns null if there is no active request. + /// + IJsonApiRequest? Current { get; } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ISchemaGenerationTraceScope.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ISchemaGenerationTraceScope.cs new file mode 100644 index 0000000000..e9cb25c7ef --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ISchemaGenerationTraceScope.cs @@ -0,0 +1,6 @@ +namespace JsonApiDotNetCore.OpenApi.Swashbuckle; + +internal interface ISchemaGenerationTraceScope : IDisposable +{ + void TraceSucceeded(string schemaId); +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/IncludeDependencyScanner.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/IncludeDependencyScanner.cs new file mode 100644 index 0000000000..97f86e2834 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/IncludeDependencyScanner.cs @@ -0,0 +1,53 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle; + +internal sealed class IncludeDependencyScanner +{ + public static IncludeDependencyScanner Instance { get; } = new(); + + private IncludeDependencyScanner() + { + } + + /// + /// Returns all related resource types that are reachable from the specified resource type. Does not include itself. + /// + public IReadOnlySet GetReachableRelatedTypes(ResourceType resourceType) + { + ArgumentNullException.ThrowIfNull(resourceType); + + HashSet resourceTypesFound = []; + + IncludeResourceType(resourceType, resourceTypesFound); + resourceTypesFound.Remove(resourceType); + + return resourceTypesFound; + } + + private static void IncludeResourceType(ResourceType resourceType, ISet resourceTypesFound) + { + if (resourceTypesFound.Add(resourceType)) + { + IncludeDerivedTypes(resourceType, resourceTypesFound); + IncludeRelatedTypes(resourceType, resourceTypesFound); + } + } + + private static void IncludeDerivedTypes(ResourceType resourceType, ISet resourceTypesFound) + { + foreach (ResourceType derivedType in resourceType.DirectlyDerivedTypes) + { + IncludeResourceType(derivedType, resourceTypesFound); + } + } + + private static void IncludeRelatedTypes(ResourceType resourceType, ISet resourceTypesFound) + { + foreach (RelationshipAttribute relationship in resourceType.Relationships) + { + IncludeResourceType(relationship.RightType, resourceTypesFound); + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiActionDescriptorCollectionProvider.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiActionDescriptorCollectionProvider.cs new file mode 100644 index 0000000000..6d63a540cd --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiActionDescriptorCollectionProvider.cs @@ -0,0 +1,225 @@ +using System.Reflection; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Documents; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.Net.Http.Headers; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle; + +/// +/// Adds JsonApiDotNetCore metadata to s if available. This translates to updating response types in +/// and performing an expansion for secondary and relationship endpoints. For example: +/// /article/{id}/author, /article/{id}/revisions, etc. +/// ]]> +/// +internal sealed class JsonApiActionDescriptorCollectionProvider : IActionDescriptorCollectionProvider +{ + private static readonly string DefaultMediaType = JsonApiMediaType.Default.ToString(); + + private readonly IActionDescriptorCollectionProvider _defaultProvider; + private readonly JsonApiEndpointMetadataProvider _jsonApiEndpointMetadataProvider; + + public ActionDescriptorCollection ActionDescriptors => GetActionDescriptors(); + + public JsonApiActionDescriptorCollectionProvider(IActionDescriptorCollectionProvider defaultProvider, + JsonApiEndpointMetadataProvider jsonApiEndpointMetadataProvider) + { + ArgumentNullException.ThrowIfNull(defaultProvider); + ArgumentNullException.ThrowIfNull(jsonApiEndpointMetadataProvider); + + _defaultProvider = defaultProvider; + _jsonApiEndpointMetadataProvider = jsonApiEndpointMetadataProvider; + } + + private ActionDescriptorCollection GetActionDescriptors() + { + List newDescriptors = _defaultProvider.ActionDescriptors.Items.ToList(); + ActionDescriptor[] endpoints = newDescriptors.Where(IsVisibleJsonApiEndpoint).ToArray(); + + foreach (ActionDescriptor endpoint in endpoints) + { + MethodInfo actionMethod = endpoint.GetActionMethod(); + JsonApiEndpointMetadataContainer endpointMetadataContainer = _jsonApiEndpointMetadataProvider.Get(actionMethod); + + List replacementDescriptorsForEndpoint = + [ + .. AddJsonApiMetadataToAction(endpoint, endpointMetadataContainer.RequestMetadata), + .. AddJsonApiMetadataToAction(endpoint, endpointMetadataContainer.ResponseMetadata) + ]; + + if (replacementDescriptorsForEndpoint.Count > 0) + { + newDescriptors.InsertRange(newDescriptors.IndexOf(endpoint), replacementDescriptorsForEndpoint); + newDescriptors.Remove(endpoint); + } + } + + int descriptorVersion = _defaultProvider.ActionDescriptors.Version; + return new ActionDescriptorCollection(newDescriptors.AsReadOnly(), descriptorVersion); + } + + internal static bool IsVisibleJsonApiEndpoint(ActionDescriptor descriptor) + { + // Only if in a convention ApiExplorer.IsVisible was set to false, the ApiDescriptionActionData will not be present. + return descriptor is ControllerActionDescriptor controllerAction && controllerAction.Properties.ContainsKey(typeof(ApiDescriptionActionData)); + } + + private static List AddJsonApiMetadataToAction(ActionDescriptor endpoint, IJsonApiEndpointMetadata? jsonApiEndpointMetadata) + { + switch (jsonApiEndpointMetadata) + { + case PrimaryResponseMetadata primaryMetadata: + { + UpdateProducesResponseTypeAttribute(endpoint, primaryMetadata.DocumentType); + return []; + } + case PrimaryRequestMetadata primaryMetadata: + { + UpdateBodyParameterDescriptor(endpoint, primaryMetadata.DocumentType, null); + return []; + } + case NonPrimaryEndpointMetadata nonPrimaryEndpointMetadata and (RelationshipResponseMetadata or SecondaryResponseMetadata): + { + return Expand(endpoint, nonPrimaryEndpointMetadata, + (expandedEndpoint, documentType, _) => UpdateProducesResponseTypeAttribute(expandedEndpoint, documentType)); + } + case NonPrimaryEndpointMetadata nonPrimaryEndpointMetadata and RelationshipRequestMetadata: + { + return Expand(endpoint, nonPrimaryEndpointMetadata, UpdateBodyParameterDescriptor); + } + case AtomicOperationsRequestMetadata: + { + UpdateBodyParameterDescriptor(endpoint, typeof(OperationsRequestDocument), null); + return []; + } + case AtomicOperationsResponseMetadata: + { + UpdateProducesResponseTypeAttribute(endpoint, typeof(OperationsResponseDocument)); + return []; + } + default: + { + return []; + } + } + } + + private static void UpdateProducesResponseTypeAttribute(ActionDescriptor endpoint, Type responseDocumentType) + { + ProducesResponseTypeAttribute? attribute = null; + + if (ProducesJsonApiResponseDocument(endpoint)) + { + var producesResponse = endpoint.GetFilterMetadata(); + + if (producesResponse != null) + { + attribute = producesResponse; + } + } + + ConsistencyGuard.ThrowIf(attribute == null); + attribute.Type = responseDocumentType; + } + + private static bool ProducesJsonApiResponseDocument(ActionDescriptor endpoint) + { + var produces = endpoint.GetFilterMetadata(); + + if (produces != null) + { + foreach (string contentType in produces.ContentTypes) + { + if (MediaTypeHeaderValue.TryParse(contentType, out MediaTypeHeaderValue? headerValue)) + { + if (headerValue.MediaType.Equals(DefaultMediaType, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + } + + return false; + } + + private static List Expand(ActionDescriptor genericEndpoint, NonPrimaryEndpointMetadata metadata, + Action expansionCallback) + { + List expansion = []; + + foreach ((string relationshipName, Type documentType) in metadata.DocumentTypesByRelationshipName) + { + if (genericEndpoint.AttributeRouteInfo == null) + { + throw new NotSupportedException("Only attribute routing is supported for JsonApiDotNetCore endpoints."); + } + + ActionDescriptor expandedEndpoint = Clone(genericEndpoint); + + RemovePathParameter(expandedEndpoint.Parameters, "relationshipName"); + + ExpandTemplate(expandedEndpoint.AttributeRouteInfo!, relationshipName); + + expansionCallback(expandedEndpoint, documentType, relationshipName); + + expansion.Add(expandedEndpoint); + } + + return expansion; + } + + private static void UpdateBodyParameterDescriptor(ActionDescriptor endpoint, Type documentType, string? parameterName) + { + ControllerParameterDescriptor? requestBodyDescriptor = endpoint.GetBodyParameterDescriptor(); + + if (requestBodyDescriptor == null) + { + MethodInfo actionMethod = endpoint.GetActionMethod(); + + throw new InvalidConfigurationException( + $"The action method '{actionMethod}' on type '{actionMethod.ReflectedType?.FullName}' contains no parameter with a [FromBody] attribute."); + } + + requestBodyDescriptor.ParameterType = documentType; + requestBodyDescriptor.ParameterInfo = new ParameterInfoWrapper(requestBodyDescriptor.ParameterInfo, documentType, parameterName); + } + + private static ActionDescriptor Clone(ActionDescriptor descriptor) + { + ActionDescriptor clone = descriptor.MemberwiseClone(); + clone.AttributeRouteInfo = descriptor.AttributeRouteInfo!.MemberwiseClone(); + clone.FilterDescriptors = descriptor.FilterDescriptors.Select(Clone).ToList(); + clone.Parameters = descriptor.Parameters.Select(parameter => parameter.MemberwiseClone()).ToList(); + return clone; + } + + private static FilterDescriptor Clone(FilterDescriptor descriptor) + { + IFilterMetadata clone = descriptor.Filter.MemberwiseClone(); + + return new FilterDescriptor(clone, descriptor.Scope) + { + Order = descriptor.Order + }; + } + + private static void RemovePathParameter(ICollection parameters, string parameterName) + { + ParameterDescriptor descriptor = parameters.Single(parameterDescriptor => parameterDescriptor.Name == parameterName); + parameters.Remove(descriptor); + } + + private static void ExpandTemplate(AttributeRouteInfo route, string expansionParameter) + { + route.Template = route.Template!.Replace("{relationshipName}", expansionParameter); + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiDotNetCore.OpenApi.Swashbuckle.csproj b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiDotNetCore.OpenApi.Swashbuckle.csproj new file mode 100644 index 0000000000..4a57ca1c85 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiDotNetCore.OpenApi.Swashbuckle.csproj @@ -0,0 +1,39 @@ + + + net8.0 + true + true + false + + + + + + $(VersionPrefix)-preview.$(OpenApiPreviewNumber) + jsonapidotnetcore;jsonapi;json:api;dotnet;asp.net;rest;web-api;openapi;swagger;swaggerui;swashbuckle + Provides OpenAPI document generation for JsonApiDotNetCore APIs by using Swashbuckle. + json-api-dotnet + https://www.jsonapi.net/ + MIT + false + See https://github.com/json-api-dotnet/JsonApiDotNetCore/releases. + package-icon.png + PackageReadme.md + true + embedded + + + + + + + + + + + + + + + + diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/AtomicOperationsRequestMetadata.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/AtomicOperationsRequestMetadata.cs new file mode 100644 index 0000000000..b9b0f44462 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/AtomicOperationsRequestMetadata.cs @@ -0,0 +1,10 @@ +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata; + +internal sealed class AtomicOperationsRequestMetadata : IJsonApiRequestMetadata +{ + public static AtomicOperationsRequestMetadata Instance { get; } = new(); + + private AtomicOperationsRequestMetadata() + { + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/AtomicOperationsResponseMetadata.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/AtomicOperationsResponseMetadata.cs new file mode 100644 index 0000000000..838055c378 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/AtomicOperationsResponseMetadata.cs @@ -0,0 +1,10 @@ +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata; + +internal sealed class AtomicOperationsResponseMetadata : IJsonApiResponseMetadata +{ + public static AtomicOperationsResponseMetadata Instance { get; } = new(); + + private AtomicOperationsResponseMetadata() + { + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/EndpointResolver.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/EndpointResolver.cs new file mode 100644 index 0000000000..e4c074f081 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/EndpointResolver.cs @@ -0,0 +1,38 @@ +using System.Reflection; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Middleware; +using Microsoft.AspNetCore.Mvc.Routing; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata; + +internal sealed class EndpointResolver +{ + public static EndpointResolver Instance { get; } = new(); + + private EndpointResolver() + { + } + + public JsonApiEndpoints GetEndpoint(MethodInfo controllerAction) + { + ArgumentNullException.ThrowIfNull(controllerAction); + + if (!IsJsonApiController(controllerAction)) + { + return JsonApiEndpoints.None; + } + + IEnumerable httpMethodAttributes = controllerAction.GetCustomAttributes(true); + return httpMethodAttributes.GetJsonApiEndpoint(); + } + + private bool IsJsonApiController(MethodInfo controllerAction) + { + return typeof(CoreJsonApiController).IsAssignableFrom(controllerAction.ReflectedType); + } + + public bool IsAtomicOperationsController(MethodInfo controllerAction) + { + return typeof(BaseJsonApiOperationsController).IsAssignableFrom(controllerAction.ReflectedType); + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/IJsonApiEndpointMetadata.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/IJsonApiEndpointMetadata.cs new file mode 100644 index 0000000000..01a8247ec5 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/IJsonApiEndpointMetadata.cs @@ -0,0 +1,3 @@ +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata; + +internal interface IJsonApiEndpointMetadata; diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/IJsonApiRequestMetadata.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/IJsonApiRequestMetadata.cs new file mode 100644 index 0000000000..86fbddebb6 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/IJsonApiRequestMetadata.cs @@ -0,0 +1,3 @@ +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata; + +internal interface IJsonApiRequestMetadata : IJsonApiEndpointMetadata; diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/IJsonApiResponseMetadata.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/IJsonApiResponseMetadata.cs new file mode 100644 index 0000000000..85fb69e856 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/IJsonApiResponseMetadata.cs @@ -0,0 +1,3 @@ +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata; + +internal interface IJsonApiResponseMetadata : IJsonApiEndpointMetadata; diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/JsonApiEndpointMetadataContainer.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/JsonApiEndpointMetadataContainer.cs new file mode 100644 index 0000000000..60b7182eb6 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/JsonApiEndpointMetadataContainer.cs @@ -0,0 +1,10 @@ +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata; + +/// +/// Metadata available at runtime about a JsonApiDotNetCore endpoint. +/// +internal sealed class JsonApiEndpointMetadataContainer(IJsonApiRequestMetadata? requestMetadata, IJsonApiResponseMetadata? responseMetadata) +{ + public IJsonApiRequestMetadata? RequestMetadata { get; } = requestMetadata; + public IJsonApiResponseMetadata? ResponseMetadata { get; } = responseMetadata; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs new file mode 100644 index 0000000000..6fd6f9e42e --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/JsonApiEndpointMetadataProvider.cs @@ -0,0 +1,123 @@ +using System.Reflection; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Documents; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata; + +/// +/// Provides JsonApiDotNetCore related metadata for an ASP.NET controller action that can only be computed from the at +/// runtime. +/// +internal sealed class JsonApiEndpointMetadataProvider +{ + private readonly IControllerResourceMapping _controllerResourceMapping; + private readonly NonPrimaryDocumentTypeFactory _nonPrimaryDocumentTypeFactory; + + public JsonApiEndpointMetadataProvider(IControllerResourceMapping controllerResourceMapping, NonPrimaryDocumentTypeFactory nonPrimaryDocumentTypeFactory) + { + ArgumentNullException.ThrowIfNull(controllerResourceMapping); + ArgumentNullException.ThrowIfNull(nonPrimaryDocumentTypeFactory); + + _controllerResourceMapping = controllerResourceMapping; + _nonPrimaryDocumentTypeFactory = nonPrimaryDocumentTypeFactory; + } + + public JsonApiEndpointMetadataContainer Get(MethodInfo controllerAction) + { + ArgumentNullException.ThrowIfNull(controllerAction); + + if (EndpointResolver.Instance.IsAtomicOperationsController(controllerAction)) + { + return new JsonApiEndpointMetadataContainer(AtomicOperationsRequestMetadata.Instance, AtomicOperationsResponseMetadata.Instance); + } + + JsonApiEndpoints endpoint = EndpointResolver.Instance.GetEndpoint(controllerAction); + + if (endpoint == JsonApiEndpoints.None) + { + throw new NotSupportedException($"Unable to provide metadata for non-JSON:API endpoint '{controllerAction.ReflectedType!.FullName}'."); + } + + ResourceType? primaryResourceType = _controllerResourceMapping.GetResourceTypeForController(controllerAction.ReflectedType); + ConsistencyGuard.ThrowIf(primaryResourceType == null); + + IJsonApiRequestMetadata? requestMetadata = GetRequestMetadata(endpoint, primaryResourceType); + IJsonApiResponseMetadata? responseMetadata = GetResponseMetadata(endpoint, primaryResourceType); + return new JsonApiEndpointMetadataContainer(requestMetadata, responseMetadata); + } + + private IJsonApiRequestMetadata? GetRequestMetadata(JsonApiEndpoints endpoint, ResourceType primaryResourceType) + { + return endpoint switch + { + JsonApiEndpoints.Post => GetPostResourceRequestMetadata(primaryResourceType.ClrType), + JsonApiEndpoints.Patch => GetPatchResourceRequestMetadata(primaryResourceType.ClrType), + JsonApiEndpoints.PostRelationship or JsonApiEndpoints.PatchRelationship or JsonApiEndpoints.DeleteRelationship => GetRelationshipRequestMetadata( + primaryResourceType.Relationships, endpoint != JsonApiEndpoints.PatchRelationship), + _ => null + }; + } + + private static PrimaryRequestMetadata GetPostResourceRequestMetadata(Type resourceClrType) + { + Type documentType = typeof(CreateRequestDocument<>).MakeGenericType(resourceClrType); + + return new PrimaryRequestMetadata(documentType); + } + + private static PrimaryRequestMetadata GetPatchResourceRequestMetadata(Type resourceClrType) + { + Type documentType = typeof(UpdateRequestDocument<>).MakeGenericType(resourceClrType); + + return new PrimaryRequestMetadata(documentType); + } + + private RelationshipRequestMetadata GetRelationshipRequestMetadata(IEnumerable relationships, bool ignoreHasOneRelationships) + { + IEnumerable relationshipsOfEndpoint = ignoreHasOneRelationships ? relationships.OfType() : relationships; + + IDictionary requestDocumentTypesByRelationshipName = relationshipsOfEndpoint.ToDictionary(relationship => relationship.PublicName, + _nonPrimaryDocumentTypeFactory.GetForRelationshipRequest); + + return new RelationshipRequestMetadata(requestDocumentTypesByRelationshipName); + } + + private IJsonApiResponseMetadata? GetResponseMetadata(JsonApiEndpoints endpoint, ResourceType primaryResourceType) + { + return endpoint switch + { + JsonApiEndpoints.GetCollection or JsonApiEndpoints.GetSingle or JsonApiEndpoints.Post or JsonApiEndpoints.Patch => GetPrimaryResponseMetadata( + primaryResourceType.ClrType, endpoint == JsonApiEndpoints.GetCollection), + JsonApiEndpoints.GetSecondary => GetSecondaryResponseMetadata(primaryResourceType.Relationships), + JsonApiEndpoints.GetRelationship => GetRelationshipResponseMetadata(primaryResourceType.Relationships), + _ => null + }; + } + + private static PrimaryResponseMetadata GetPrimaryResponseMetadata(Type resourceClrType, bool endpointReturnsCollection) + { + Type documentOpenType = endpointReturnsCollection ? typeof(CollectionResponseDocument<>) : typeof(PrimaryResponseDocument<>); + Type documentType = documentOpenType.MakeGenericType(resourceClrType); + + return new PrimaryResponseMetadata(documentType); + } + + private SecondaryResponseMetadata GetSecondaryResponseMetadata(IEnumerable relationships) + { + IDictionary responseDocumentTypesByRelationshipName = relationships.ToDictionary(relationship => relationship.PublicName, + _nonPrimaryDocumentTypeFactory.GetForSecondaryResponse); + + return new SecondaryResponseMetadata(responseDocumentTypesByRelationshipName); + } + + private RelationshipResponseMetadata GetRelationshipResponseMetadata(IEnumerable relationships) + { + IDictionary responseDocumentTypesByRelationshipName = relationships.ToDictionary(relationship => relationship.PublicName, + _nonPrimaryDocumentTypeFactory.GetForRelationshipResponse); + + return new RelationshipResponseMetadata(responseDocumentTypesByRelationshipName); + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/NonPrimaryDocumentTypeFactory.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/NonPrimaryDocumentTypeFactory.cs new file mode 100644 index 0000000000..7550d59ab3 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/NonPrimaryDocumentTypeFactory.cs @@ -0,0 +1,80 @@ +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Documents; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Relationships; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata; + +internal sealed class NonPrimaryDocumentTypeFactory +{ + private static readonly DocumentOpenTypes SecondaryResponseDocumentOpenTypes = new(typeof(CollectionResponseDocument<>), + typeof(NullableSecondaryResponseDocument<>), typeof(SecondaryResponseDocument<>)); + + private static readonly DocumentOpenTypes RelationshipRequestDocumentOpenTypes = new(typeof(ToManyInRequest<>), + typeof(NullableToOneInRequest<>), typeof(ToOneInRequest<>)); + + private static readonly DocumentOpenTypes RelationshipResponseDocumentOpenTypes = new(typeof(IdentifierCollectionResponseDocument<>), + typeof(NullableIdentifierResponseDocument<>), typeof(IdentifierResponseDocument<>)); + + private readonly ResourceFieldValidationMetadataProvider _resourceFieldValidationMetadataProvider; + + public NonPrimaryDocumentTypeFactory(ResourceFieldValidationMetadataProvider resourceFieldValidationMetadataProvider) + { + ArgumentNullException.ThrowIfNull(resourceFieldValidationMetadataProvider); + + _resourceFieldValidationMetadataProvider = resourceFieldValidationMetadataProvider; + } + + public Type GetForSecondaryResponse(RelationshipAttribute relationship) + { + ArgumentNullException.ThrowIfNull(relationship); + + return Get(relationship, SecondaryResponseDocumentOpenTypes); + } + + public Type GetForRelationshipRequest(RelationshipAttribute relationship) + { + ArgumentNullException.ThrowIfNull(relationship); + + return Get(relationship, RelationshipRequestDocumentOpenTypes); + } + + public Type GetForRelationshipResponse(RelationshipAttribute relationship) + { + ArgumentNullException.ThrowIfNull(relationship); + + return Get(relationship, RelationshipResponseDocumentOpenTypes); + } + + private Type Get(RelationshipAttribute relationship, DocumentOpenTypes types) + { + // @formatter:nested_ternary_style expanded + + Type documentOpenType = relationship is HasManyAttribute + ? types.ManyDataOpenType + : _resourceFieldValidationMetadataProvider.IsNullable(relationship) + ? types.NullableSingleDataOpenType + : types.SingleDataOpenType; + + // @formatter:nested_ternary_style restore + + return documentOpenType.MakeGenericType(relationship.RightType.ClrType); + } + + private sealed class DocumentOpenTypes + { + public Type ManyDataOpenType { get; } + public Type NullableSingleDataOpenType { get; } + public Type SingleDataOpenType { get; } + + public DocumentOpenTypes(Type manyDataOpenType, Type nullableSingleDataOpenType, Type singleDataOpenType) + { + ArgumentNullException.ThrowIfNull(manyDataOpenType); + ArgumentNullException.ThrowIfNull(nullableSingleDataOpenType); + ArgumentNullException.ThrowIfNull(singleDataOpenType); + + ManyDataOpenType = manyDataOpenType; + NullableSingleDataOpenType = nullableSingleDataOpenType; + SingleDataOpenType = singleDataOpenType; + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/NonPrimaryEndpointMetadata.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/NonPrimaryEndpointMetadata.cs new file mode 100644 index 0000000000..ed43dc4da8 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/NonPrimaryEndpointMetadata.cs @@ -0,0 +1,13 @@ +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata; + +internal abstract class NonPrimaryEndpointMetadata +{ + public IDictionary DocumentTypesByRelationshipName { get; } + + protected NonPrimaryEndpointMetadata(IDictionary documentTypesByRelationshipName) + { + ArgumentNullException.ThrowIfNull(documentTypesByRelationshipName); + + DocumentTypesByRelationshipName = documentTypesByRelationshipName; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/PrimaryRequestMetadata.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/PrimaryRequestMetadata.cs new file mode 100644 index 0000000000..7c224417f1 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/PrimaryRequestMetadata.cs @@ -0,0 +1,13 @@ +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata; + +internal sealed class PrimaryRequestMetadata : IJsonApiRequestMetadata +{ + public Type DocumentType { get; } + + public PrimaryRequestMetadata(Type documentType) + { + ArgumentNullException.ThrowIfNull(documentType); + + DocumentType = documentType; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/PrimaryResponseMetadata.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/PrimaryResponseMetadata.cs new file mode 100644 index 0000000000..2d2590be7d --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/PrimaryResponseMetadata.cs @@ -0,0 +1,13 @@ +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata; + +internal sealed class PrimaryResponseMetadata : IJsonApiResponseMetadata +{ + public Type DocumentType { get; } + + public PrimaryResponseMetadata(Type documentType) + { + ArgumentNullException.ThrowIfNull(documentType); + + DocumentType = documentType; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/RelationshipRequestMetadata.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/RelationshipRequestMetadata.cs new file mode 100644 index 0000000000..e2636da079 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/RelationshipRequestMetadata.cs @@ -0,0 +1,4 @@ +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata; + +internal sealed class RelationshipRequestMetadata(IDictionary documentTypesByRelationshipName) + : NonPrimaryEndpointMetadata(documentTypesByRelationshipName), IJsonApiRequestMetadata; diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/RelationshipResponseMetadata.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/RelationshipResponseMetadata.cs new file mode 100644 index 0000000000..7221dfbe5e --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/RelationshipResponseMetadata.cs @@ -0,0 +1,4 @@ +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata; + +internal sealed class RelationshipResponseMetadata(IDictionary documentTypesByRelationshipName) + : NonPrimaryEndpointMetadata(documentTypesByRelationshipName), IJsonApiResponseMetadata; diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/RelationshipTypeFactory.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/RelationshipTypeFactory.cs new file mode 100644 index 0000000000..be5a6fd30c --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/RelationshipTypeFactory.cs @@ -0,0 +1,44 @@ +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Relationships; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata; + +internal sealed class RelationshipTypeFactory +{ + private readonly NonPrimaryDocumentTypeFactory _nonPrimaryDocumentTypeFactory; + private readonly ResourceFieldValidationMetadataProvider _resourceFieldValidationMetadataProvider; + + public RelationshipTypeFactory(NonPrimaryDocumentTypeFactory nonPrimaryDocumentTypeFactory, + ResourceFieldValidationMetadataProvider resourceFieldValidationMetadataProvider) + { + ArgumentNullException.ThrowIfNull(nonPrimaryDocumentTypeFactory); + ArgumentNullException.ThrowIfNull(resourceFieldValidationMetadataProvider); + + _nonPrimaryDocumentTypeFactory = nonPrimaryDocumentTypeFactory; + _resourceFieldValidationMetadataProvider = resourceFieldValidationMetadataProvider; + } + + public Type GetForRequest(RelationshipAttribute relationship) + { + ArgumentNullException.ThrowIfNull(relationship); + + return _nonPrimaryDocumentTypeFactory.GetForRelationshipRequest(relationship); + } + + public Type GetForResponse(RelationshipAttribute relationship) + { + ArgumentNullException.ThrowIfNull(relationship); + + // @formatter:nested_ternary_style expanded + + Type relationshipDataOpenType = relationship is HasManyAttribute + ? typeof(ToManyInResponse<>) + : _resourceFieldValidationMetadataProvider.IsNullable(relationship) + ? typeof(NullableToOneInResponse<>) + : typeof(ToOneInResponse<>); + + // @formatter:nested_ternary_style restore + + return relationshipDataOpenType.MakeGenericType(relationship.RightType.ClrType); + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/SecondaryResponseMetadata.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/SecondaryResponseMetadata.cs new file mode 100644 index 0000000000..39b8ce8d4f --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiMetadata/SecondaryResponseMetadata.cs @@ -0,0 +1,4 @@ +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata; + +internal sealed class SecondaryResponseMetadata(IDictionary documentTypesByRelationshipName) + : NonPrimaryEndpointMetadata(documentTypesByRelationshipName), IJsonApiResponseMetadata; diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/AtomicOperations/AddToRelationshipOperation.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/AtomicOperations/AddToRelationshipOperation.cs new file mode 100644 index 0000000000..bf461fe41a --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/AtomicOperations/AddToRelationshipOperation.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.AtomicOperations; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class AddToRelationshipOperation : AtomicOperation + where TResource : IIdentifiable +{ + [Required] + [JsonPropertyName("op")] + public string Op { get; set; } = null!; + + [Required] + [JsonPropertyName("ref")] + public object Ref { get; set; } = null!; + + [Required] + [JsonPropertyName("data")] + public ICollection> Data { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/AtomicOperations/AtomicOperation.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/AtomicOperations/AtomicOperation.cs new file mode 100644 index 0000000000..6f0934008d --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/AtomicOperations/AtomicOperation.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.AtomicOperations; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal abstract class AtomicOperation : IHasMeta +{ + [JsonPropertyName("meta")] + public Meta Meta { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/AtomicOperations/AtomicResult.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/AtomicOperations/AtomicResult.cs new file mode 100644 index 0000000000..f4dec21cbf --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/AtomicOperations/AtomicResult.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.AtomicOperations; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class AtomicResult : IHasMeta +{ + [JsonPropertyName("data")] + public ResourceInResponse Data { get; set; } = null!; + + [JsonPropertyName("meta")] + public Meta Meta { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/AtomicOperations/CreateOperation.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/AtomicOperations/CreateOperation.cs new file mode 100644 index 0000000000..266712e666 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/AtomicOperations/CreateOperation.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.AtomicOperations; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class CreateOperation : AtomicOperation + where TResource : IIdentifiable +{ + [Required] + [JsonPropertyName("op")] + public string Op { get; set; } = null!; + + [Required] + [JsonPropertyName("data")] + public DataInCreateRequest Data { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/AtomicOperations/DeleteOperation.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/AtomicOperations/DeleteOperation.cs new file mode 100644 index 0000000000..838b4400dd --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/AtomicOperations/DeleteOperation.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.AtomicOperations; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class DeleteOperation : AtomicOperation + where TResource : IIdentifiable +{ + [Required] + [JsonPropertyName("op")] + public string Op { get; set; } = null!; + + [Required] + [JsonPropertyName("ref")] + public IdentifierInRequest Ref { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/AtomicOperations/RemoveFromRelationshipOperation.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/AtomicOperations/RemoveFromRelationshipOperation.cs new file mode 100644 index 0000000000..eafe36028e --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/AtomicOperations/RemoveFromRelationshipOperation.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.AtomicOperations; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class RemoveFromRelationshipOperation : AtomicOperation + where TResource : IIdentifiable +{ + [Required] + [JsonPropertyName("op")] + public string Op { get; set; } = null!; + + [Required] + [JsonPropertyName("ref")] + public object Ref { get; set; } = null!; + + [Required] + [JsonPropertyName("data")] + public ICollection> Data { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/AtomicOperations/UpdateOperation.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/AtomicOperations/UpdateOperation.cs new file mode 100644 index 0000000000..cb576c3a4f --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/AtomicOperations/UpdateOperation.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.AtomicOperations; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class UpdateOperation : AtomicOperation + where TResource : IIdentifiable +{ + [Required] + [JsonPropertyName("op")] + public string Op { get; set; } = null!; + + [JsonPropertyName("ref")] + public IdentifierInRequest Ref { get; set; } = null!; + + [Required] + [JsonPropertyName("data")] + public DataInUpdateRequest Data { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/AtomicOperations/UpdateToManyRelationshipOperation.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/AtomicOperations/UpdateToManyRelationshipOperation.cs new file mode 100644 index 0000000000..2725b3dd9f --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/AtomicOperations/UpdateToManyRelationshipOperation.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.AtomicOperations; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class UpdateToManyRelationshipOperation : AtomicOperation + where TResource : IIdentifiable +{ + [Required] + [JsonPropertyName("op")] + public string Op { get; set; } = null!; + + [Required] + [JsonPropertyName("ref")] + public object Ref { get; set; } = null!; + + [Required] + [JsonPropertyName("data")] + public ICollection> Data { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/AtomicOperations/UpdateToOneRelationshipOperation.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/AtomicOperations/UpdateToOneRelationshipOperation.cs new file mode 100644 index 0000000000..71c6528605 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/AtomicOperations/UpdateToOneRelationshipOperation.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.AtomicOperations; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class UpdateToOneRelationshipOperation : AtomicOperation + where TResource : IIdentifiable +{ + [Required] + [JsonPropertyName("op")] + public string Op { get; set; } = null!; + + [Required] + [JsonPropertyName("ref")] + public object Ref { get; set; } = null!; + + [Required] + [JsonPropertyName("data")] + // Nullability of this property is determined based on the nullability of the to-one relationship. + public IdentifierInRequest? Data { get; set; } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/CollectionResponseDocument.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/CollectionResponseDocument.cs new file mode 100644 index 0000000000..bfb0131f07 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/CollectionResponseDocument.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Links; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Documents; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class CollectionResponseDocument : IHasMeta + where TResource : IIdentifiable +{ + [JsonPropertyName("jsonapi")] + public Jsonapi Jsonapi { get; set; } = null!; + + [Required] + [JsonPropertyName("links")] + public ResourceCollectionTopLevelLinks Links { get; set; } = null!; + + [Required] + [JsonPropertyName("data")] + public ICollection> Data { get; set; } = null!; + + [JsonPropertyName("included")] + public IList Included { get; set; } = null!; + + [JsonPropertyName("meta")] + public Meta Meta { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/CreateRequestDocument.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/CreateRequestDocument.cs new file mode 100644 index 0000000000..388d659398 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/CreateRequestDocument.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Documents; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class CreateRequestDocument : IHasMeta + where TResource : IIdentifiable +{ + [Required] + [JsonPropertyName("data")] + public DataInCreateRequest Data { get; set; } = null!; + + [JsonPropertyName("meta")] + public Meta Meta { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/ErrorResponseDocument.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/ErrorResponseDocument.cs new file mode 100644 index 0000000000..750c83ef74 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/ErrorResponseDocument.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Links; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Documents; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class ErrorResponseDocument : IHasMeta +{ + [JsonPropertyName("jsonapi")] + public Jsonapi Jsonapi { get; set; } = null!; + + [Required] + [JsonPropertyName("links")] + public ErrorTopLevelLinks Links { get; set; } = null!; + + [Required] + [JsonPropertyName("errors")] + public IList Errors { get; set; } = new List(); + + [JsonPropertyName("meta")] + public Meta Meta { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/IdentifierCollectionResponseDocument.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/IdentifierCollectionResponseDocument.cs new file mode 100644 index 0000000000..bf50821888 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/IdentifierCollectionResponseDocument.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Links; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Documents; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class IdentifierCollectionResponseDocument : IHasMeta + where TResource : IIdentifiable +{ + [JsonPropertyName("jsonapi")] + public Jsonapi Jsonapi { get; set; } = null!; + + [Required] + [JsonPropertyName("links")] + public ResourceIdentifierCollectionTopLevelLinks Links { get; set; } = null!; + + [Required] + [JsonPropertyName("data")] + public ICollection> Data { get; set; } = null!; + + [JsonPropertyName("meta")] + public Meta Meta { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/IdentifierResponseDocument.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/IdentifierResponseDocument.cs new file mode 100644 index 0000000000..3c44955edc --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/IdentifierResponseDocument.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Links; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Documents; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class IdentifierResponseDocument : IHasMeta + where TResource : IIdentifiable +{ + [JsonPropertyName("jsonapi")] + public Jsonapi Jsonapi { get; set; } = null!; + + [Required] + [JsonPropertyName("links")] + public ResourceIdentifierTopLevelLinks Links { get; set; } = null!; + + [Required] + [JsonPropertyName("data")] + public IdentifierInResponse Data { get; set; } = null!; + + [JsonPropertyName("meta")] + public Meta Meta { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/NullableIdentifierResponseDocument.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/NullableIdentifierResponseDocument.cs new file mode 100644 index 0000000000..1386bec001 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/NullableIdentifierResponseDocument.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Links; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Documents; + +// Types in the JsonApiObjects namespace are never touched by ASP.NET ModelState validation, therefore using a non-nullable reference type for a property does not +// imply this property is required. Instead, we use [Required] explicitly, because this is how Swashbuckle is instructed to mark properties as required. +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class NullableIdentifierResponseDocument : IHasMeta + where TResource : IIdentifiable +{ + [JsonPropertyName("jsonapi")] + public Jsonapi Jsonapi { get; set; } = null!; + + [Required] + [JsonPropertyName("links")] + public ResourceIdentifierTopLevelLinks Links { get; set; } = null!; + + [Required] + [JsonPropertyName("data")] + public IdentifierInResponse? Data { get; set; } + + [JsonPropertyName("meta")] + public Meta Meta { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/NullableSecondaryResponseDocument.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/NullableSecondaryResponseDocument.cs new file mode 100644 index 0000000000..6d6d240a5b --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/NullableSecondaryResponseDocument.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Links; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Documents; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class NullableSecondaryResponseDocument : IHasMeta + where TResource : IIdentifiable +{ + [JsonPropertyName("jsonapi")] + public Jsonapi Jsonapi { get; set; } = null!; + + [Required] + [JsonPropertyName("links")] + public ResourceTopLevelLinks Links { get; set; } = null!; + + [Required] + [JsonPropertyName("data")] + public DataInResponse? Data { get; set; } + + [JsonPropertyName("included")] + public IList Included { get; set; } = null!; + + [JsonPropertyName("meta")] + public Meta Meta { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/OperationsRequestDocument.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/OperationsRequestDocument.cs new file mode 100644 index 0000000000..6bd3a1dad2 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/OperationsRequestDocument.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.AtomicOperations; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Documents; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class OperationsRequestDocument : IHasMeta +{ + [Required] + [MinLength(1)] + [JsonPropertyName("atomic:operations")] + public ICollection Operations { get; set; } = null!; + + [JsonPropertyName("meta")] + public Meta Meta { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/OperationsResponseDocument.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/OperationsResponseDocument.cs new file mode 100644 index 0000000000..1c962fba84 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/OperationsResponseDocument.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.AtomicOperations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Links; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Documents; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class OperationsResponseDocument : IHasMeta +{ + [JsonPropertyName("jsonapi")] + public Jsonapi Jsonapi { get; set; } = null!; + + [Required] + [JsonPropertyName("links")] + public ResourceTopLevelLinks Links { get; set; } = null!; + + [Required] + [MinLength(1)] + [JsonPropertyName("atomic:results")] + public IList Results { get; set; } = null!; + + [JsonPropertyName("meta")] + public Meta Meta { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/PrimaryResponseDocument.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/PrimaryResponseDocument.cs new file mode 100644 index 0000000000..1956fb8758 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/PrimaryResponseDocument.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Links; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Documents; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class PrimaryResponseDocument : IHasMeta + where TResource : IIdentifiable +{ + [JsonPropertyName("jsonapi")] + public Jsonapi Jsonapi { get; set; } = null!; + + [Required] + [JsonPropertyName("links")] + public ResourceTopLevelLinks Links { get; set; } = null!; + + [Required] + [JsonPropertyName("data")] + public DataInResponse Data { get; set; } = null!; + + [JsonPropertyName("included")] + public IList Included { get; set; } = null!; + + [JsonPropertyName("meta")] + public Meta Meta { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/SecondaryResponseDocument.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/SecondaryResponseDocument.cs new file mode 100644 index 0000000000..6a90db36e1 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/SecondaryResponseDocument.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Links; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Documents; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class SecondaryResponseDocument : IHasMeta + where TResource : IIdentifiable +{ + [JsonPropertyName("jsonapi")] + public Jsonapi Jsonapi { get; set; } = null!; + + [Required] + [JsonPropertyName("links")] + public ResourceTopLevelLinks Links { get; set; } = null!; + + [Required] + [JsonPropertyName("data")] + public DataInResponse Data { get; set; } = null!; + + [JsonPropertyName("included")] + public IList Included { get; set; } = null!; + + [JsonPropertyName("meta")] + public Meta Meta { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/UpdateRequestDocument.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/UpdateRequestDocument.cs new file mode 100644 index 0000000000..cbeec3e86b --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Documents/UpdateRequestDocument.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Documents; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class UpdateRequestDocument : IHasMeta + where TResource : IIdentifiable +{ + [Required] + [JsonPropertyName("data")] + public DataInUpdateRequest Data { get; set; } = null!; + + [JsonPropertyName("meta")] + public Meta Meta { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/IHasMeta.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/IHasMeta.cs new file mode 100644 index 0000000000..64315dee42 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/IHasMeta.cs @@ -0,0 +1,9 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal interface IHasMeta +{ + Meta Meta { get; set; } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Jsonapi.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Jsonapi.cs new file mode 100644 index 0000000000..692b5d77ac --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Jsonapi.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class Jsonapi : IHasMeta +{ + [JsonPropertyName("version")] + public string Version { get; set; } = null!; + + [JsonPropertyName("ext")] + public ICollection Ext { get; set; } = null!; + + [JsonPropertyName("profile")] + public ICollection Profile { get; set; } = null!; + + [JsonPropertyName("meta")] + public Meta Meta { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Links/ErrorTopLevelLinks.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Links/ErrorTopLevelLinks.cs new file mode 100644 index 0000000000..f98c9eb480 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Links/ErrorTopLevelLinks.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Links; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class ErrorTopLevelLinks +{ + [JsonPropertyName("self")] + public string Self { get; set; } = null!; + + [JsonPropertyName("describedby")] + public string Describedby { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Links/RelationshipLinks.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Links/RelationshipLinks.cs new file mode 100644 index 0000000000..0bdae58dee --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Links/RelationshipLinks.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Links; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class RelationshipLinks +{ + [JsonPropertyName("self")] + public string Self { get; set; } = null!; + + [JsonPropertyName("related")] + public string Related { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Links/ResourceCollectionTopLevelLinks.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Links/ResourceCollectionTopLevelLinks.cs new file mode 100644 index 0000000000..65710af45f --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Links/ResourceCollectionTopLevelLinks.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Links; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class ResourceCollectionTopLevelLinks +{ + [JsonPropertyName("self")] + public string Self { get; set; } = null!; + + [JsonPropertyName("describedby")] + public string Describedby { get; set; } = null!; + + [JsonPropertyName("first")] + public string First { get; set; } = null!; + + [JsonPropertyName("last")] + public string Last { get; set; } = null!; + + [JsonPropertyName("prev")] + public string Prev { get; set; } = null!; + + [JsonPropertyName("next")] + public string Next { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Links/ResourceIdentifierCollectionTopLevelLinks.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Links/ResourceIdentifierCollectionTopLevelLinks.cs new file mode 100644 index 0000000000..8b9a3aaf55 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Links/ResourceIdentifierCollectionTopLevelLinks.cs @@ -0,0 +1,29 @@ +using System.Text.Json.Serialization; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Links; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class ResourceIdentifierCollectionTopLevelLinks +{ + [JsonPropertyName("self")] + public string Self { get; set; } = null!; + + [JsonPropertyName("related")] + public string Related { get; set; } = null!; + + [JsonPropertyName("describedby")] + public string Describedby { get; set; } = null!; + + [JsonPropertyName("first")] + public string First { get; set; } = null!; + + [JsonPropertyName("last")] + public string Last { get; set; } = null!; + + [JsonPropertyName("prev")] + public string Prev { get; set; } = null!; + + [JsonPropertyName("next")] + public string Next { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Links/ResourceIdentifierTopLevelLinks.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Links/ResourceIdentifierTopLevelLinks.cs new file mode 100644 index 0000000000..9af21cf6a1 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Links/ResourceIdentifierTopLevelLinks.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Links; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class ResourceIdentifierTopLevelLinks +{ + [JsonPropertyName("self")] + public string Self { get; set; } = null!; + + [JsonPropertyName("related")] + public string Related { get; set; } = null!; + + [JsonPropertyName("describedby")] + public string Describedby { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Links/ResourceLinks.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Links/ResourceLinks.cs new file mode 100644 index 0000000000..a37ef69523 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Links/ResourceLinks.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Links; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class ResourceLinks +{ + [JsonPropertyName("self")] + public string Self { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Links/ResourceTopLevelLinks.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Links/ResourceTopLevelLinks.cs new file mode 100644 index 0000000000..531c8d10ad --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Links/ResourceTopLevelLinks.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Links; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class ResourceTopLevelLinks +{ + [JsonPropertyName("self")] + public string Self { get; set; } = null!; + + [JsonPropertyName("describedby")] + public string Describedby { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Meta.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Meta.cs new file mode 100644 index 0000000000..a85cd3498a --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Meta.cs @@ -0,0 +1,9 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class Meta +{ + // No members, because the component schema is custom-generated. +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Relationships/NullableToOneInRequest.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Relationships/NullableToOneInRequest.cs new file mode 100644 index 0000000000..13f3290102 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Relationships/NullableToOneInRequest.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Relationships; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class NullableToOneInRequest : IHasMeta + where TResource : IIdentifiable +{ + [Required] + [JsonPropertyName("data")] + public IdentifierInRequest? Data { get; set; } + + [JsonPropertyName("meta")] + public Meta Meta { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Relationships/NullableToOneInResponse.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Relationships/NullableToOneInResponse.cs new file mode 100644 index 0000000000..29d13dd1c2 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Relationships/NullableToOneInResponse.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Links; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Relationships; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class NullableToOneInResponse : IHasMeta + where TResource : IIdentifiable +{ + // Non-required because the related controller may be unavailable when used in an include. + [JsonPropertyName("links")] + public RelationshipLinks Links { get; set; } = null!; + + // Non-required because related data may not be included in the response. + [JsonPropertyName("data")] + public IdentifierInResponse? Data { get; set; } + + [JsonPropertyName("meta")] + public Meta Meta { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Relationships/ToManyInRequest.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Relationships/ToManyInRequest.cs new file mode 100644 index 0000000000..b80ab3a004 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Relationships/ToManyInRequest.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Relationships; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class ToManyInRequest : IHasMeta + where TResource : IIdentifiable +{ + [Required] + [JsonPropertyName("data")] + public ICollection> Data { get; set; } = null!; + + [JsonPropertyName("meta")] + public Meta Meta { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Relationships/ToManyInResponse.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Relationships/ToManyInResponse.cs new file mode 100644 index 0000000000..e60ab7f451 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Relationships/ToManyInResponse.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Links; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Relationships; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class ToManyInResponse : IHasMeta + where TResource : IIdentifiable +{ + // Non-required because the related controller may be unavailable when used in an include. + [JsonPropertyName("links")] + public RelationshipLinks Links { get; set; } = null!; + + // Non-required because related data may not be included in the response. + [JsonPropertyName("data")] + public ICollection> Data { get; set; } = null!; + + [JsonPropertyName("meta")] + public Meta Meta { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Relationships/ToOneInRequest.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Relationships/ToOneInRequest.cs new file mode 100644 index 0000000000..6404e84545 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Relationships/ToOneInRequest.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Relationships; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class ToOneInRequest : IHasMeta + where TResource : IIdentifiable +{ + [Required] + [JsonPropertyName("data")] + public IdentifierInRequest Data { get; set; } = null!; + + [JsonPropertyName("meta")] + public Meta Meta { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Relationships/ToOneInResponse.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Relationships/ToOneInResponse.cs new file mode 100644 index 0000000000..859a1de929 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/Relationships/ToOneInResponse.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Links; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Relationships; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class ToOneInResponse : IHasMeta + where TResource : IIdentifiable +{ + // Non-required because the related controller may be unavailable when used in an include. + [JsonPropertyName("links")] + public RelationshipLinks Links { get; set; } = null!; + + // Non-required because related data may not be included in the response. + [JsonPropertyName("data")] + public IdentifierInResponse Data { get; set; } = null!; + + [JsonPropertyName("meta")] + public Meta Meta { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/AttributesInCreateRequest.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/AttributesInCreateRequest.cs new file mode 100644 index 0000000000..0b8fddc0da --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/AttributesInCreateRequest.cs @@ -0,0 +1,9 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; + +internal abstract class AttributesInCreateRequest; + +// ReSharper disable once UnusedTypeParameter +internal sealed class AttributesInCreateRequest : AttributesInCreateRequest + where TResource : IIdentifiable; diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/AttributesInResponse.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/AttributesInResponse.cs new file mode 100644 index 0000000000..d4b1bb728c --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/AttributesInResponse.cs @@ -0,0 +1,9 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; + +internal abstract class AttributesInResponse; + +// ReSharper disable once UnusedTypeParameter +internal sealed class AttributesInResponse : AttributesInResponse + where TResource : IIdentifiable; diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/AttributesInUpdateRequest.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/AttributesInUpdateRequest.cs new file mode 100644 index 0000000000..d41bf18782 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/AttributesInUpdateRequest.cs @@ -0,0 +1,9 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; + +internal abstract class AttributesInUpdateRequest; + +// ReSharper disable once UnusedTypeParameter +internal sealed class AttributesInUpdateRequest : AttributesInUpdateRequest + where TResource : IIdentifiable; diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/DataInCreateRequest.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/DataInCreateRequest.cs new file mode 100644 index 0000000000..ac416a1614 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/DataInCreateRequest.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class DataInCreateRequest : ResourceInCreateRequest + where TResource : IIdentifiable +{ + [MinLength(1)] + [JsonPropertyName("id")] + public override string Id { get; set; } = null!; + + [MinLength(1)] + [JsonPropertyName("lid")] + public string Lid { get; set; } = null!; + + [JsonPropertyName("attributes")] + public AttributesInCreateRequest Attributes { get; set; } = null!; + + [JsonPropertyName("relationships")] + public RelationshipsInCreateRequest Relationships { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/DataInResponse.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/DataInResponse.cs new file mode 100644 index 0000000000..6015a3511d --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/DataInResponse.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Links; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class DataInResponse : ResourceInResponse + where TResource : IIdentifiable +{ + [Required] + [JsonPropertyName("id")] + public override string Id { get; set; } = null!; + + [JsonPropertyName("attributes")] + public AttributesInResponse Attributes { get; set; } = null!; + + [JsonPropertyName("relationships")] + public RelationshipsInResponse Relationships { get; set; } = null!; + + // Non-required because the related controller may be unavailable when used in an include. + [JsonPropertyName("links")] + public ResourceLinks Links { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/DataInUpdateRequest.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/DataInUpdateRequest.cs new file mode 100644 index 0000000000..25167a5437 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/DataInUpdateRequest.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class DataInUpdateRequest : ResourceInUpdateRequest + where TResource : IIdentifiable +{ + [MinLength(1)] + [JsonPropertyName("id")] + public override string Id { get; set; } = null!; + + [MinLength(1)] + [JsonPropertyName("lid")] + public string Lid { get; set; } = null!; + + [JsonPropertyName("attributes")] + public AttributesInUpdateRequest Attributes { get; set; } = null!; + + [JsonPropertyName("relationships")] + public RelationshipsInUpdateRequest Relationships { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/IResourceIdentity.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/IResourceIdentity.cs new file mode 100644 index 0000000000..52985d68d9 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/IResourceIdentity.cs @@ -0,0 +1,10 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal interface IResourceIdentity : IHasMeta +{ + string Type { get; set; } + string Id { get; set; } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/IdentifierInRequest.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/IdentifierInRequest.cs new file mode 100644 index 0000000000..f317498810 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/IdentifierInRequest.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; + +internal class IdentifierInRequest : IHasMeta +{ + [Required] + [JsonPropertyName("type")] + public string Type { get; set; } = null!; + + [JsonPropertyName("meta")] + public Meta Meta { get; set; } = null!; +} + +// ReSharper disable once UnusedTypeParameter +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal class IdentifierInRequest : IdentifierInRequest, IResourceIdentity + where TResource : IIdentifiable +{ + [MinLength(1)] + [JsonPropertyName("id")] + public string Id { get; set; } = null!; + + [MinLength(1)] + [JsonPropertyName("lid")] + public string Lid { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/IdentifierInResponse.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/IdentifierInResponse.cs new file mode 100644 index 0000000000..47bf94ce17 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/IdentifierInResponse.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; + +// ReSharper disable once UnusedTypeParameter +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class IdentifierInResponse : IResourceIdentity + where TResource : IIdentifiable +{ + [Required] + [JsonPropertyName("type")] + public string Type { get; set; } = null!; + + [Required] + [JsonPropertyName("id")] + public string Id { get; set; } = null!; + + [JsonPropertyName("meta")] + public Meta Meta { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/RelationshipIdentifier.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/RelationshipIdentifier.cs new file mode 100644 index 0000000000..34afbf1dc2 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/RelationshipIdentifier.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal sealed class RelationshipIdentifier : IdentifierInRequest + where TResource : IIdentifiable +{ + [Required] + [JsonPropertyName("relationship")] + public string Relationship { get; set; } = null!; + + // Meta is erased at runtime. +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/RelationshipsInCreateRequest.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/RelationshipsInCreateRequest.cs new file mode 100644 index 0000000000..44698c3b57 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/RelationshipsInCreateRequest.cs @@ -0,0 +1,9 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; + +internal abstract class RelationshipsInCreateRequest; + +// ReSharper disable once UnusedTypeParameter +internal sealed class RelationshipsInCreateRequest : RelationshipsInCreateRequest + where TResource : IIdentifiable; diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/RelationshipsInResponse.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/RelationshipsInResponse.cs new file mode 100644 index 0000000000..24222743ce --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/RelationshipsInResponse.cs @@ -0,0 +1,9 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; + +internal abstract class RelationshipsInResponse; + +// ReSharper disable once UnusedTypeParameter +internal sealed class RelationshipsInResponse : RelationshipsInResponse + where TResource : IIdentifiable; diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/RelationshipsInUpdateRequest.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/RelationshipsInUpdateRequest.cs new file mode 100644 index 0000000000..bc851b97d5 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/RelationshipsInUpdateRequest.cs @@ -0,0 +1,9 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; + +internal abstract class RelationshipsInUpdateRequest; + +// ReSharper disable once UnusedTypeParameter +internal sealed class RelationshipsInUpdateRequest : RelationshipsInUpdateRequest + where TResource : IIdentifiable; diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/ResourceData.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/ResourceData.cs new file mode 100644 index 0000000000..746c696545 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/ResourceData.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +internal abstract class ResourceData : IResourceIdentity +{ + [Required] + [JsonPropertyName("type")] + public string Type { get; set; } = null!; + + public abstract string Id { get; set; } + + [JsonPropertyName("meta")] + public Meta Meta { get; set; } = null!; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/ResourceInCreateRequest.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/ResourceInCreateRequest.cs new file mode 100644 index 0000000000..838ab6a4ca --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/ResourceInCreateRequest.cs @@ -0,0 +1,3 @@ +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; + +internal abstract class ResourceInCreateRequest : ResourceData; diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/ResourceInResponse.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/ResourceInResponse.cs new file mode 100644 index 0000000000..2df01cc319 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/ResourceInResponse.cs @@ -0,0 +1,3 @@ +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; + +internal abstract class ResourceInResponse : ResourceData; diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/ResourceInUpdateRequest.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/ResourceInUpdateRequest.cs new file mode 100644 index 0000000000..63e5faae72 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiObjects/ResourceObjects/ResourceInUpdateRequest.cs @@ -0,0 +1,3 @@ +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; + +internal abstract class ResourceInUpdateRequest : ResourceData; diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiPropertyName.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiPropertyName.cs new file mode 100644 index 0000000000..1526450f16 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiPropertyName.cs @@ -0,0 +1,19 @@ +#pragma warning disable AV1008 // Class should not be static + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle; + +internal static class JsonApiPropertyName +{ + public const string Jsonapi = "jsonapi"; + public const string Links = "links"; + public const string Data = "data"; + public const string Type = "type"; + public const string Id = "id"; + public const string Lid = "lid"; + public const string Attributes = "attributes"; + public const string Relationships = "relationships"; + public const string Op = "op"; + public const string Ref = "ref"; + public const string Relationship = "relationship"; + public const string Meta = "meta"; +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiRequestAccessor.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiRequestAccessor.cs new file mode 100644 index 0000000000..b466d78442 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiRequestAccessor.cs @@ -0,0 +1,21 @@ +using JsonApiDotNetCore.Middleware; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle; + +/// +internal sealed class JsonApiRequestAccessor : IJsonApiRequestAccessor +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + /// + public IJsonApiRequest? Current => _httpContextAccessor.HttpContext?.RequestServices.GetService(); + + public JsonApiRequestAccessor(IHttpContextAccessor httpContextAccessor) + { + ArgumentNullException.ThrowIfNull(httpContextAccessor); + + _httpContextAccessor = httpContextAccessor; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiRequestFormatMetadataProvider.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiRequestFormatMetadataProvider.cs new file mode 100644 index 0000000000..6def822bd9 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiRequestFormatMetadataProvider.cs @@ -0,0 +1,42 @@ +using System.Diagnostics; +using JsonApiDotNetCore.Middleware; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Net.Http.Headers; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle; + +internal sealed class JsonApiRequestFormatMetadataProvider : IInputFormatter, IApiRequestFormatMetadataProvider +{ + private static readonly string DefaultMediaType = JsonApiMediaType.Default.ToString(); + + /// + public bool CanRead(InputFormatterContext context) + { + return false; + } + + /// + public Task ReadAsync(InputFormatterContext context) + { + throw new UnreachableException(); + } + + /// + public IReadOnlyList GetSupportedContentTypes(string contentType, Type objectType) + { + ArgumentException.ThrowIfNullOrEmpty(contentType); + ArgumentNullException.ThrowIfNull(objectType); + + if (JsonApiSchemaFacts.IsRequestBodySchemaType(objectType) && MediaTypeHeaderValue.TryParse(contentType, out MediaTypeHeaderValue? headerValue) && + headerValue.MediaType.Equals(DefaultMediaType, StringComparison.OrdinalIgnoreCase)) + { + return new MediaTypeCollection + { + headerValue + }; + } + + return []; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiSchemaFacts.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiSchemaFacts.cs new file mode 100644 index 0000000000..bf91aed0e7 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiSchemaFacts.cs @@ -0,0 +1,54 @@ +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Documents; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Relationships; + +#pragma warning disable AV1008 // Class should not be static + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle; + +internal static class JsonApiSchemaFacts +{ + private static readonly Type[] RequestBodySchemaTypes = + [ + typeof(CreateRequestDocument<>), + typeof(UpdateRequestDocument<>), + typeof(ToOneInRequest<>), + typeof(NullableToOneInRequest<>), + typeof(ToManyInRequest<>), + typeof(OperationsRequestDocument) + ]; + + private static readonly Type[] SchemaTypesHavingNullableDataProperty = + [ + typeof(NullableToOneInRequest<>), + typeof(NullableToOneInResponse<>), + typeof(NullableSecondaryResponseDocument<>), + typeof(NullableIdentifierResponseDocument<>) + ]; + + private static readonly Type[] RelationshipInResponseSchemaTypes = + [ + typeof(ToOneInResponse<>), + typeof(ToManyInResponse<>), + typeof(NullableToOneInResponse<>) + ]; + + public static bool IsRequestBodySchemaType(Type schemaType) + { + Type lookupType = schemaType.ConstructedToOpenType(); + return RequestBodySchemaTypes.Contains(lookupType); + } + + public static bool HasNullableDataProperty(Type schemaType) + { + // Swashbuckle infers non-nullable because our Data properties are [Required]. + + Type lookupType = schemaType.ConstructedToOpenType(); + return SchemaTypesHavingNullableDataProperty.Contains(lookupType); + } + + public static bool IsRelationshipInResponseType(Type schemaType) + { + Type lookupType = schemaType.ConstructedToOpenType(); + return RelationshipInResponseSchemaTypes.Contains(lookupType); + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiSchemaIdSelector.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiSchemaIdSelector.cs new file mode 100644 index 0000000000..0e2fc803de --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/JsonApiSchemaIdSelector.cs @@ -0,0 +1,208 @@ +using System.Text.Json; +using Humanizer; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.AtomicOperations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Documents; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Relationships; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle; + +internal sealed class JsonApiSchemaIdSelector +{ + private const string ResourceTypeSchemaIdTemplate = "[ResourceName] Resource Type"; + private const string MetaSchemaIdTemplate = "Meta"; + + private const string ResourceAtomicOperationDiscriminatorValueTemplate = "[OperationCode] [ResourceName]"; + private const string UpdateRelationshipAtomicOperationDiscriminatorValueTemplate = "Update [ResourceName] [RelationshipName]"; + private const string AddToRelationshipAtomicOperationDiscriminatorValueTemplate = "Add To [ResourceName] [RelationshipName]"; + private const string RemoveFromRelationshipAtomicOperationDiscriminatorValueTemplate = "Remove From [ResourceName] [RelationshipName]"; + + private const string UpdateRelationshipAtomicOperationSchemaIdTemplate = "Update [ResourceName] [RelationshipName] Relationship Operation"; + private const string AddToRelationshipAtomicOperationSchemaIdTemplate = "Add To [ResourceName] [RelationshipName] Relationship Operation"; + private const string RemoveFromRelationshipAtomicOperationSchemaIdTemplate = "Remove From [ResourceName] [RelationshipName] Relationship Operation"; + private const string RelationshipIdentifierSchemaIdTemplate = "[ResourceName] [RelationshipName] Relationship Identifier"; + private const string RelationshipNameSchemaIdTemplate = "[ResourceName] [RelationshipName] Relationship Name"; + + private static readonly Dictionary SchemaTypeToTemplateMap = new() + { + [typeof(CreateRequestDocument<>)] = "Create [ResourceName] Request Document", + [typeof(UpdateRequestDocument<>)] = "Update [ResourceName] Request Document", + [typeof(ResourceInCreateRequest)] = "Resource In Create Request", + [typeof(DataInCreateRequest<>)] = "Data In Create [ResourceName] Request", + [typeof(AttributesInCreateRequest)] = "Attributes In Create Request", + [typeof(AttributesInCreateRequest<>)] = "Attributes In Create [ResourceName] Request", + [typeof(RelationshipsInCreateRequest)] = "Relationships In Create Request", + [typeof(RelationshipsInCreateRequest<>)] = "Relationships In Create [ResourceName] Request", + [typeof(ResourceInUpdateRequest)] = "Resource In Update Request", + [typeof(DataInUpdateRequest<>)] = "Data In Update [ResourceName] Request", + [typeof(AttributesInUpdateRequest)] = "Attributes In Update Request", + [typeof(AttributesInUpdateRequest<>)] = "Attributes In Update [ResourceName] Request", + [typeof(RelationshipsInUpdateRequest)] = "Relationships In Update Request", + [typeof(RelationshipsInUpdateRequest<>)] = "Relationships In Update [ResourceName] Request", + [typeof(ToOneInRequest<>)] = "To One [ResourceName] In Request", + [typeof(NullableToOneInRequest<>)] = "Nullable To One [ResourceName] In Request", + [typeof(ToManyInRequest<>)] = "To Many [ResourceName] In Request", + [typeof(PrimaryResponseDocument<>)] = "Primary [ResourceName] Response Document", + [typeof(SecondaryResponseDocument<>)] = "Secondary [ResourceName] Response Document", + [typeof(NullableSecondaryResponseDocument<>)] = "Nullable Secondary [ResourceName] Response Document", + [typeof(CollectionResponseDocument<>)] = "[ResourceName] Collection Response Document", + [typeof(IdentifierResponseDocument<>)] = "[ResourceName] Identifier Response Document", + [typeof(NullableIdentifierResponseDocument<>)] = "Nullable [ResourceName] Identifier Response Document", + [typeof(IdentifierCollectionResponseDocument<>)] = "[ResourceName] Identifier Collection Response Document", + [typeof(ToOneInResponse<>)] = "To One [ResourceName] In Response", + [typeof(NullableToOneInResponse<>)] = "Nullable To One [ResourceName] In Response", + [typeof(ToManyInResponse<>)] = "To Many [ResourceName] In Response", + [typeof(ResourceInResponse)] = "Resource In Response", + [typeof(DataInResponse<>)] = "Data In [ResourceName] Response", + [typeof(AttributesInResponse<>)] = "Attributes In [ResourceName] Response", + [typeof(RelationshipsInResponse<>)] = "Relationships In [ResourceName] Response", + [typeof(IdentifierInRequest)] = "Identifier In Request", + [typeof(IdentifierInRequest<>)] = "[ResourceName] Identifier In Request", + [typeof(IdentifierInResponse<>)] = "[ResourceName] Identifier In Response", + [typeof(CreateOperation<>)] = "Create [ResourceName] Operation", + [typeof(UpdateOperation<>)] = "Update [ResourceName] Operation", + [typeof(DeleteOperation<>)] = "Delete [ResourceName] Operation", + [typeof(UpdateToOneRelationshipOperation<>)] = "Temporary Update [ResourceName] To One Relationship Operation", + [typeof(UpdateToManyRelationshipOperation<>)] = "Temporary Update [ResourceName] To Many Relationship Operation", + [typeof(AddToRelationshipOperation<>)] = "Temporary Add To [ResourceName] Relationship Operation", + [typeof(RemoveFromRelationshipOperation<>)] = "Temporary Remove From [ResourceName] Relationship Operation" + }; + + private readonly IJsonApiOptions _options; + private readonly IResourceGraph _resourceGraph; + + public JsonApiSchemaIdSelector(IJsonApiOptions options, IResourceGraph resourceGraph) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(resourceGraph); + + _options = options; + _resourceGraph = resourceGraph; + } + + public string GetSchemaId(Type type) + { + ArgumentNullException.ThrowIfNull(type); + + ResourceType? resourceType = _resourceGraph.FindResourceType(type); + + if (resourceType != null) + { + return resourceType.PublicName.Singularize(); + } + + Type openType = type.ConstructedToOpenType(); + + if (openType != type) + { + if (SchemaTypeToTemplateMap.TryGetValue(openType, out string? schemaTemplate)) + { + Type resourceClrType = type.GetGenericArguments().First(); + resourceType = _resourceGraph.GetResourceType(resourceClrType); + + return ApplySchemaTemplate(schemaTemplate, resourceType, null, null); + } + } + else + { + if (SchemaTypeToTemplateMap.TryGetValue(type, out string? schemaTemplate)) + { + return ApplySchemaTemplate(schemaTemplate, null, null, null); + } + } + + // Used for a fixed set of non-generic types, such as Jsonapi, ResourceCollectionTopLevelLinks etc. + return ApplySchemaTemplate(type.Name, null, null, null); + } + + private string ApplySchemaTemplate(string schemaTemplate, ResourceType? resourceType, string? relationshipName, AtomicOperationCode? operationCode) + { + string schemaId = schemaTemplate; + + schemaId = resourceType != null + ? schemaId.Replace("[ResourceName]", resourceType.PublicName.Singularize()).Pascalize() + : schemaId.Replace("[ResourceName]", "$$$").Pascalize().Replace("$$$", string.Empty); + + if (relationshipName != null) + { + schemaId = schemaId.Replace("[RelationshipName]", relationshipName.Pascalize()); + } + + if (operationCode != null) + { + schemaId = schemaId.Replace("[OperationCode]", operationCode.Value.ToString().Pascalize()); + } + + string pascalCaseSchemaId = schemaId.Pascalize(); + + JsonNamingPolicy? namingPolicy = _options.SerializerOptions.PropertyNamingPolicy; + return namingPolicy != null ? namingPolicy.ConvertName(pascalCaseSchemaId) : pascalCaseSchemaId; + } + + public string GetResourceTypeSchemaId(ResourceType? resourceType) + { + return ApplySchemaTemplate(ResourceTypeSchemaIdTemplate, resourceType, null, null); + } + + public string GetMetaSchemaId() + { + return ApplySchemaTemplate(MetaSchemaIdTemplate, null, null, null); + } + + public string GetAtomicOperationCodeSchemaId(AtomicOperationCode operationCode) + { + return ApplySchemaTemplate("[OperationCode] Operation Code", null, null, operationCode); + } + + public string GetAtomicOperationDiscriminatorValue(AtomicOperationCode operationCode, ResourceType resourceType) + { + ArgumentNullException.ThrowIfNull(resourceType); + + return ApplySchemaTemplate(ResourceAtomicOperationDiscriminatorValueTemplate, resourceType, null, operationCode); + } + + public string GetAtomicOperationDiscriminatorValue(AtomicOperationCode operationCode, RelationshipAttribute relationship) + { + ArgumentNullException.ThrowIfNull(relationship); + + string schemaIdTemplate = operationCode switch + { + AtomicOperationCode.Add => AddToRelationshipAtomicOperationDiscriminatorValueTemplate, + AtomicOperationCode.Remove => RemoveFromRelationshipAtomicOperationDiscriminatorValueTemplate, + _ => UpdateRelationshipAtomicOperationDiscriminatorValueTemplate + }; + + return ApplySchemaTemplate(schemaIdTemplate, relationship.LeftType, relationship.PublicName, null); + } + + public string GetRelationshipAtomicOperationSchemaId(RelationshipAttribute relationship, AtomicOperationCode operationCode) + { + ArgumentNullException.ThrowIfNull(relationship); + + string schemaIdTemplate = operationCode switch + { + AtomicOperationCode.Add => AddToRelationshipAtomicOperationSchemaIdTemplate, + AtomicOperationCode.Remove => RemoveFromRelationshipAtomicOperationSchemaIdTemplate, + _ => UpdateRelationshipAtomicOperationSchemaIdTemplate + }; + + return ApplySchemaTemplate(schemaIdTemplate, relationship.LeftType, relationship.PublicName, null); + } + + public string GetRelationshipIdentifierSchemaId(RelationshipAttribute relationship) + { + ArgumentNullException.ThrowIfNull(relationship); + + return ApplySchemaTemplate(RelationshipIdentifierSchemaIdTemplate, relationship.LeftType, relationship.PublicName, null); + } + + public string GetRelationshipNameSchemaId(RelationshipAttribute relationship) + { + ArgumentNullException.ThrowIfNull(relationship); + + return ApplySchemaTemplate(RelationshipNameSchemaIdTemplate, relationship.LeftType, relationship.PublicName, null); + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ObjectExtensions.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ObjectExtensions.cs new file mode 100644 index 0000000000..accd03d7c4 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ObjectExtensions.cs @@ -0,0 +1,18 @@ +using System.Reflection; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle; + +internal static class ObjectExtensions +{ + private static readonly Lazy MemberwiseCloneMethod = + new(() => typeof(object).GetMethod(nameof(MemberwiseClone), BindingFlags.Instance | BindingFlags.NonPublic)!, + LazyThreadSafetyMode.ExecutionAndPublication); + + public static T MemberwiseClone(this T source) + where T : class + { + ArgumentNullException.ThrowIfNull(source); + + return (T)MemberwiseCloneMethod.Value.Invoke(source, null)!; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiApplicationBuilderEvents.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiApplicationBuilderEvents.cs new file mode 100644 index 0000000000..b9cfddd772 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiApplicationBuilderEvents.cs @@ -0,0 +1,23 @@ +using JsonApiDotNetCore.Configuration; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle; + +internal sealed class OpenApiApplicationBuilderEvents : IJsonApiApplicationBuilderEvents +{ + private readonly IJsonApiOptions _options; + private readonly IJsonApiRequestAccessor _requestAccessor; + + public OpenApiApplicationBuilderEvents(IJsonApiOptions options, IJsonApiRequestAccessor requestAccessor) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(requestAccessor); + + _options = options; + _requestAccessor = requestAccessor; + } + + public void ResourceGraphBuilt(IResourceGraph resourceGraph) + { + _options.SerializerOptions.Converters.Add(new OpenApiResourceObjectConverter(resourceGraph, _requestAccessor)); + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiContentNegotiator.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiContentNegotiator.cs new file mode 100644 index 0000000000..2678cb122e --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiContentNegotiator.cs @@ -0,0 +1,71 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using Microsoft.AspNetCore.Http; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle; + +internal sealed class OpenApiContentNegotiator(IJsonApiOptions options, IHttpContextAccessor httpContextAccessor) + : JsonApiContentNegotiator(options, httpContextAccessor) +{ + private readonly IJsonApiOptions _options = options; + + protected override JsonApiMediaType? GetDefaultMediaType(IReadOnlyList possibleMediaTypes, JsonApiMediaType? requestMediaType) + { + if (requestMediaType != null && possibleMediaTypes.Contains(requestMediaType)) + { + // Bug workaround: NSwag doesn't send an Accept header when only non-success responses define a Content-Type. + // This occurs on POST/PATCH/DELETE at a JSON:API relationships endpoint. + return requestMediaType; + } + + return base.GetDefaultMediaType(possibleMediaTypes, requestMediaType); + } + + protected override IReadOnlyList GetPossibleMediaTypes() + { + List mediaTypes = []; + + // JSON:API compliant entries come after relaxed entries, which makes them less likely to be selected. + // This improves compatibility with client generators, which often generate broken code due to the double quotes. + + if (IsOperationsEndpoint()) + { + if (_options.Extensions.Contains(JsonApiMediaTypeExtension.RelaxedAtomicOperations)) + { + mediaTypes.Add(JsonApiMediaType.RelaxedAtomicOperations); + } + + if (_options.Extensions.Contains(JsonApiMediaTypeExtension.AtomicOperations)) + { + mediaTypes.Add(JsonApiMediaType.AtomicOperations); + } + + if (_options.Extensions.Contains(JsonApiMediaTypeExtension.RelaxedAtomicOperations) && + _options.Extensions.Contains(OpenApiMediaTypeExtension.RelaxedOpenApi)) + { + mediaTypes.Add(OpenApiMediaTypes.RelaxedAtomicOperationsWithRelaxedOpenApi); + } + + if (_options.Extensions.Contains(JsonApiMediaTypeExtension.AtomicOperations) && _options.Extensions.Contains(OpenApiMediaTypeExtension.OpenApi)) + { + mediaTypes.Add(OpenApiMediaTypes.AtomicOperationsWithOpenApi); + } + } + else + { + if (_options.Extensions.Contains(OpenApiMediaTypeExtension.RelaxedOpenApi)) + { + mediaTypes.Add(OpenApiMediaTypes.RelaxedOpenApi); + } + + if (_options.Extensions.Contains(OpenApiMediaTypeExtension.OpenApi)) + { + mediaTypes.Add(OpenApiMediaTypes.OpenApi); + } + + mediaTypes.Add(JsonApiMediaType.Default); + } + + return mediaTypes.AsReadOnly(); + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiDescriptionLinkProvider.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiDescriptionLinkProvider.cs new file mode 100644 index 0000000000..278c2154a9 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiDescriptionLinkProvider.cs @@ -0,0 +1,41 @@ +using JsonApiDotNetCore.Serialization.Response; +using Microsoft.Extensions.Options; +using Swashbuckle.AspNetCore.Swagger; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle; + +/// +/// Provides the OpenAPI URL for the "describedby" link in https://jsonapi.org/format/#document-top-level. +/// +internal sealed class OpenApiDescriptionLinkProvider : IDocumentDescriptionLinkProvider +{ + private readonly IOptionsMonitor _swaggerGeneratorOptionsMonitor; + private readonly IOptionsMonitor _swaggerOptionsMonitor; + + public OpenApiDescriptionLinkProvider(IOptionsMonitor swaggerGeneratorOptionsMonitor, + IOptionsMonitor swaggerOptionsMonitor) + { + ArgumentNullException.ThrowIfNull(swaggerGeneratorOptionsMonitor); + ArgumentNullException.ThrowIfNull(swaggerOptionsMonitor); + + _swaggerGeneratorOptionsMonitor = swaggerGeneratorOptionsMonitor; + _swaggerOptionsMonitor = swaggerOptionsMonitor; + } + + /// + public string? GetUrl() + { + SwaggerGeneratorOptions swaggerGeneratorOptions = _swaggerGeneratorOptionsMonitor.CurrentValue; + + if (swaggerGeneratorOptions.SwaggerDocs.Count > 0) + { + string latestVersionDocumentName = swaggerGeneratorOptions.SwaggerDocs.Last().Key; + + SwaggerOptions swaggerOptions = _swaggerOptionsMonitor.CurrentValue; + return swaggerOptions.RouteTemplate.Replace("{documentName}", latestVersionDocumentName).Replace("{extension:regex(^(json|ya?ml)$)}", "json"); + } + + return null; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiEndpointConvention.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiEndpointConvention.cs new file mode 100644 index 0000000000..75649b85a8 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiEndpointConvention.cs @@ -0,0 +1,348 @@ +using System.Net; +using System.Reflection; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Documents; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApplicationModels; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle; + +/// +/// Sets metadata on controllers for OpenAPI documentation generation by Swagger. Only targets JsonApiDotNetCore controllers. +/// +internal sealed class OpenApiEndpointConvention : IActionModelConvention +{ + private readonly IControllerResourceMapping _controllerResourceMapping; + private readonly IJsonApiOptions _options; + + public OpenApiEndpointConvention(IControllerResourceMapping controllerResourceMapping, IJsonApiOptions options) + { + ArgumentNullException.ThrowIfNull(controllerResourceMapping); + ArgumentNullException.ThrowIfNull(options); + + _controllerResourceMapping = controllerResourceMapping; + _options = options; + } + + public void Apply(ActionModel action) + { + ArgumentNullException.ThrowIfNull(action); + + JsonApiEndpointWrapper endpoint = JsonApiEndpointWrapper.FromActionModel(action); + + if (endpoint.IsUnknown) + { + // Not a JSON:API controller, or a non-standard action method in a JSON:API controller. + // None of these are yet implemented, so hide them to avoid downstream crashes. + action.ApiExplorer.IsVisible = false; + return; + } + + ResourceType? resourceType = _controllerResourceMapping.GetResourceTypeForController(action.Controller.ControllerType); + + if (ShouldSuppressEndpoint(endpoint, resourceType)) + { + action.ApiExplorer.IsVisible = false; + return; + } + + SetResponseMetadata(action, endpoint, resourceType); + SetRequestMetadata(action, endpoint); + } + + private bool ShouldSuppressEndpoint(JsonApiEndpointWrapper endpoint, ResourceType? resourceType) + { + if (resourceType == null) + { + return false; + } + + if (!IsEndpointAvailable(endpoint.Value, resourceType)) + { + return true; + } + + if (IsSecondaryOrRelationshipEndpoint(endpoint.Value)) + { + if (resourceType.Relationships.Count == 0) + { + return true; + } + + if (endpoint.Value is JsonApiEndpoints.DeleteRelationship or JsonApiEndpoints.PostRelationship) + { + return !resourceType.Relationships.OfType().Any(); + } + } + + return false; + } + + private static bool IsEndpointAvailable(JsonApiEndpoints endpoint, ResourceType resourceType) + { + JsonApiEndpoints availableEndpoints = GetGeneratedControllerEndpoints(resourceType); + + if (availableEndpoints == JsonApiEndpoints.None) + { + // Auto-generated controllers are disabled, so we can't know what to hide. + // It is assumed that a handwritten JSON:API controller only provides action methods for what it supports. + // To accomplish that, derive from BaseJsonApiController instead of JsonApiController. + return true; + } + + // For an overridden JSON:API action method in a partial class to show up, it's flag must be turned on in [Resource]. + // Otherwise, it is considered to be an action method that throws because the endpoint is unavailable. + return IncludesEndpoint(endpoint, availableEndpoints); + } + + private static bool IncludesEndpoint(JsonApiEndpoints endpoint, JsonApiEndpoints availableEndpoints) + { + bool? isIncluded = null; + + if (endpoint == JsonApiEndpoints.GetCollection) + { + isIncluded = availableEndpoints.HasFlag(JsonApiEndpoints.GetCollection); + } + else if (endpoint == JsonApiEndpoints.GetSingle) + { + isIncluded = availableEndpoints.HasFlag(JsonApiEndpoints.GetSingle); + } + else if (endpoint == JsonApiEndpoints.GetSecondary) + { + isIncluded = availableEndpoints.HasFlag(JsonApiEndpoints.GetSecondary); + } + else if (endpoint == JsonApiEndpoints.GetRelationship) + { + isIncluded = availableEndpoints.HasFlag(JsonApiEndpoints.GetRelationship); + } + else if (endpoint == JsonApiEndpoints.Post) + { + isIncluded = availableEndpoints.HasFlag(JsonApiEndpoints.Post); + } + else if (endpoint == JsonApiEndpoints.PostRelationship) + { + isIncluded = availableEndpoints.HasFlag(JsonApiEndpoints.PostRelationship); + } + else if (endpoint == JsonApiEndpoints.Patch) + { + isIncluded = availableEndpoints.HasFlag(JsonApiEndpoints.Patch); + } + else if (endpoint == JsonApiEndpoints.PatchRelationship) + { + isIncluded = availableEndpoints.HasFlag(JsonApiEndpoints.PatchRelationship); + } + else if (endpoint == JsonApiEndpoints.Delete) + { + isIncluded = availableEndpoints.HasFlag(JsonApiEndpoints.Delete); + } + else if (endpoint == JsonApiEndpoints.DeleteRelationship) + { + isIncluded = availableEndpoints.HasFlag(JsonApiEndpoints.DeleteRelationship); + } + + ConsistencyGuard.ThrowIf(isIncluded == null); + return isIncluded.Value; + } + + private static JsonApiEndpoints GetGeneratedControllerEndpoints(ResourceType resourceType) + { + var resourceAttribute = resourceType.ClrType.GetCustomAttribute(); + return resourceAttribute?.GenerateControllerEndpoints ?? JsonApiEndpoints.None; + } + + private static bool IsSecondaryOrRelationshipEndpoint(JsonApiEndpoints endpoint) + { + return endpoint is JsonApiEndpoints.GetSecondary or JsonApiEndpoints.GetRelationship or JsonApiEndpoints.PostRelationship or + JsonApiEndpoints.PatchRelationship or JsonApiEndpoints.DeleteRelationship; + } + + private void SetResponseMetadata(ActionModel action, JsonApiEndpointWrapper endpoint, ResourceType? resourceType) + { + JsonApiMediaType mediaType = GetMediaTypeForEndpoint(endpoint); + action.Filters.Add(new ProducesAttribute(mediaType.ToString())); + + foreach (HttpStatusCode statusCode in GetSuccessStatusCodesForEndpoint(endpoint)) + { + // The return type is set later by JsonApiActionDescriptorCollectionProvider. + action.Filters.Add(new ProducesResponseTypeAttribute((int)statusCode)); + } + + foreach (HttpStatusCode statusCode in GetErrorStatusCodesForEndpoint(endpoint, resourceType)) + { + action.Filters.Add(new ProducesResponseTypeAttribute(typeof(ErrorResponseDocument), (int)statusCode)); + } + } + + private JsonApiMediaType GetMediaTypeForEndpoint(JsonApiEndpointWrapper endpoint) + { + return endpoint.IsAtomicOperationsEndpoint ? OpenApiMediaTypes.RelaxedAtomicOperationsWithRelaxedOpenApi : OpenApiMediaTypes.RelaxedOpenApi; + } + + private static HttpStatusCode[] GetSuccessStatusCodesForEndpoint(JsonApiEndpointWrapper endpoint) + { + if (endpoint.IsAtomicOperationsEndpoint) + { + return + [ + HttpStatusCode.OK, + HttpStatusCode.NoContent + ]; + } + + HttpStatusCode[]? statusCodes = null; + + if (endpoint.Value is JsonApiEndpoints.GetCollection or JsonApiEndpoints.GetSingle or JsonApiEndpoints.GetSecondary or JsonApiEndpoints.GetRelationship) + { + statusCodes = + [ + HttpStatusCode.OK, + HttpStatusCode.NotModified + ]; + } + else if (endpoint.Value == JsonApiEndpoints.Post) + { + statusCodes = + [ + HttpStatusCode.Created, + HttpStatusCode.NoContent + ]; + } + else if (endpoint.Value == JsonApiEndpoints.Patch) + { + statusCodes = + [ + HttpStatusCode.OK, + HttpStatusCode.NoContent + ]; + } + else if (endpoint.Value is JsonApiEndpoints.Delete or JsonApiEndpoints.PostRelationship or JsonApiEndpoints.PatchRelationship or + JsonApiEndpoints.DeleteRelationship) + { + statusCodes = [HttpStatusCode.NoContent]; + } + + ConsistencyGuard.ThrowIf(statusCodes == null); + return statusCodes; + } + + private HttpStatusCode[] GetErrorStatusCodesForEndpoint(JsonApiEndpointWrapper endpoint, ResourceType? resourceType) + { + if (endpoint.IsAtomicOperationsEndpoint) + { + return + [ + HttpStatusCode.BadRequest, + HttpStatusCode.Forbidden, + HttpStatusCode.NotFound, + HttpStatusCode.Conflict, + HttpStatusCode.UnprocessableEntity + ]; + } + + // Condition doesn't apply to atomic operations, because Forbidden is also used when an operation is not accessible. + ClientIdGenerationMode clientIdGeneration = resourceType?.ClientIdGeneration ?? _options.ClientIdGeneration; + + HttpStatusCode[]? statusCodes = null; + + if (endpoint.Value == JsonApiEndpoints.GetCollection) + { + statusCodes = [HttpStatusCode.BadRequest]; + } + else if (endpoint.Value is JsonApiEndpoints.GetSingle or JsonApiEndpoints.GetSecondary or JsonApiEndpoints.GetRelationship) + { + statusCodes = + [ + HttpStatusCode.BadRequest, + HttpStatusCode.NotFound + ]; + } + else if (endpoint.Value == JsonApiEndpoints.Post && clientIdGeneration == ClientIdGenerationMode.Forbidden) + { + statusCodes = + [ + HttpStatusCode.BadRequest, + HttpStatusCode.Forbidden, + HttpStatusCode.NotFound, + HttpStatusCode.Conflict, + HttpStatusCode.UnprocessableEntity + ]; + } + else if (endpoint.Value is JsonApiEndpoints.Post or JsonApiEndpoints.Patch) + { + statusCodes = + [ + HttpStatusCode.BadRequest, + HttpStatusCode.NotFound, + HttpStatusCode.Conflict, + HttpStatusCode.UnprocessableEntity + ]; + } + else if (endpoint.Value == JsonApiEndpoints.Delete) + { + statusCodes = [HttpStatusCode.NotFound]; + } + else if (endpoint.Value is JsonApiEndpoints.PostRelationship or JsonApiEndpoints.PatchRelationship or JsonApiEndpoints.DeleteRelationship) + { + statusCodes = + [ + HttpStatusCode.BadRequest, + HttpStatusCode.NotFound, + HttpStatusCode.Conflict, + HttpStatusCode.UnprocessableEntity + ]; + } + + ConsistencyGuard.ThrowIf(statusCodes == null); + return statusCodes; + } + + private void SetRequestMetadata(ActionModel action, JsonApiEndpointWrapper endpoint) + { + if (RequiresRequestBody(endpoint)) + { + JsonApiMediaType mediaType = GetMediaTypeForEndpoint(endpoint); + action.Filters.Add(new ConsumesAttribute(mediaType.ToString())); + } + } + + private static bool RequiresRequestBody(JsonApiEndpointWrapper endpoint) + { + return endpoint.IsAtomicOperationsEndpoint || endpoint.Value is JsonApiEndpoints.Post or JsonApiEndpoints.Patch or JsonApiEndpoints.PostRelationship or + JsonApiEndpoints.PatchRelationship or JsonApiEndpoints.DeleteRelationship; + } + + private sealed class JsonApiEndpointWrapper + { + private static readonly JsonApiEndpointWrapper AtomicOperations = new(true, JsonApiEndpoints.None); + + public bool IsAtomicOperationsEndpoint { get; } + public JsonApiEndpoints Value { get; } + public bool IsUnknown => !IsAtomicOperationsEndpoint && Value == JsonApiEndpoints.None; + + private JsonApiEndpointWrapper(bool isAtomicOperationsEndpoint, JsonApiEndpoints value) + { + IsAtomicOperationsEndpoint = isAtomicOperationsEndpoint; + Value = value; + } + + public static JsonApiEndpointWrapper FromActionModel(ActionModel actionModel) + { + if (EndpointResolver.Instance.IsAtomicOperationsController(actionModel.ActionMethod)) + { + return AtomicOperations; + } + + JsonApiEndpoints endpoint = EndpointResolver.Instance.GetEndpoint(actionModel.ActionMethod); + return new JsonApiEndpointWrapper(false, endpoint); + } + + public override string ToString() + { + return IsAtomicOperationsEndpoint ? "PostOperations" : Value.ToString(); + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiMediaTypeExtension.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiMediaTypeExtension.cs new file mode 100644 index 0000000000..64abe96e97 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiMediaTypeExtension.cs @@ -0,0 +1,14 @@ +using JsonApiDotNetCore.Middleware; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle; + +#pragma warning disable AV1008 // Class should not be static + +internal static class OpenApiMediaTypeExtension +{ + public const string ExtensionNamespace = "openapi"; + public const string DiscriminatorPropertyName = "discriminator"; + public const string FullyQualifiedOpenApiDiscriminatorPropertyName = $"{ExtensionNamespace}:{DiscriminatorPropertyName}"; + public static readonly JsonApiMediaTypeExtension OpenApi = new("https://www.jsonapi.net/ext/openapi"); + public static readonly JsonApiMediaTypeExtension RelaxedOpenApi = new("openapi"); +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiMediaTypes.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiMediaTypes.cs new file mode 100644 index 0000000000..29cc134c34 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiMediaTypes.cs @@ -0,0 +1,21 @@ +using JsonApiDotNetCore.Middleware; + +#pragma warning disable AV1008 // Class should not be static + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle; + +internal static class OpenApiMediaTypes +{ + public static readonly JsonApiMediaType OpenApi = new([OpenApiMediaTypeExtension.OpenApi]); + public static readonly JsonApiMediaType RelaxedOpenApi = new([OpenApiMediaTypeExtension.RelaxedOpenApi]); + + public static readonly JsonApiMediaType AtomicOperationsWithOpenApi = new([ + JsonApiMediaTypeExtension.AtomicOperations, + OpenApiMediaTypeExtension.OpenApi + ]); + + public static readonly JsonApiMediaType RelaxedAtomicOperationsWithRelaxedOpenApi = new([ + JsonApiMediaTypeExtension.RelaxedAtomicOperations, + OpenApiMediaTypeExtension.RelaxedOpenApi + ]); +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiOperationIdSelector.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiOperationIdSelector.cs new file mode 100644 index 0000000000..ed11481e27 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiOperationIdSelector.cs @@ -0,0 +1,109 @@ +using System.Reflection; +using System.Text.Json; +using Humanizer; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Documents; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Relationships; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Controllers; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle; + +internal sealed class OpenApiOperationIdSelector +{ + private const string ResourceIdTemplate = "[Method] [PrimaryResourceName]"; + private const string ResourceCollectionIdTemplate = $"{ResourceIdTemplate} Collection"; + private const string SecondaryResourceIdTemplate = $"{ResourceIdTemplate} [RelationshipName]"; + private const string RelationshipIdTemplate = $"{SecondaryResourceIdTemplate} Relationship"; + private const string AtomicOperationsIdTemplate = "[Method] Operations"; + + private static readonly Dictionary SchemaOpenTypeToOpenApiOperationIdTemplateMap = new() + { + [typeof(CollectionResponseDocument<>)] = ResourceCollectionIdTemplate, + [typeof(PrimaryResponseDocument<>)] = ResourceIdTemplate, + [typeof(CreateRequestDocument<>)] = ResourceIdTemplate, + [typeof(UpdateRequestDocument<>)] = ResourceIdTemplate, + [typeof(void)] = ResourceIdTemplate, + [typeof(SecondaryResponseDocument<>)] = SecondaryResourceIdTemplate, + [typeof(NullableSecondaryResponseDocument<>)] = SecondaryResourceIdTemplate, + [typeof(IdentifierCollectionResponseDocument<>)] = RelationshipIdTemplate, + [typeof(IdentifierResponseDocument<>)] = RelationshipIdTemplate, + [typeof(NullableIdentifierResponseDocument<>)] = RelationshipIdTemplate, + [typeof(ToOneInRequest<>)] = RelationshipIdTemplate, + [typeof(NullableToOneInRequest<>)] = RelationshipIdTemplate, + [typeof(ToManyInRequest<>)] = RelationshipIdTemplate, + [typeof(OperationsRequestDocument)] = AtomicOperationsIdTemplate + }; + + private readonly IControllerResourceMapping _controllerResourceMapping; + private readonly IJsonApiOptions _options; + + public OpenApiOperationIdSelector(IControllerResourceMapping controllerResourceMapping, IJsonApiOptions options) + { + ArgumentNullException.ThrowIfNull(controllerResourceMapping); + ArgumentNullException.ThrowIfNull(options); + + _controllerResourceMapping = controllerResourceMapping; + _options = options; + } + + public string GetOpenApiOperationId(ApiDescription endpoint) + { + ArgumentNullException.ThrowIfNull(endpoint); + + MethodInfo actionMethod = endpoint.ActionDescriptor.GetActionMethod(); + ResourceType? primaryResourceType = _controllerResourceMapping.GetResourceTypeForController(actionMethod.ReflectedType); + + string template = GetTemplate(endpoint); + return ApplyTemplate(template, primaryResourceType, endpoint); + } + + private static string GetTemplate(ApiDescription endpoint) + { + Type bodyType = GetBodyType(endpoint); + ConsistencyGuard.ThrowIf(!SchemaOpenTypeToOpenApiOperationIdTemplateMap.TryGetValue(bodyType, out string? template)); + return template; + } + + private static Type GetBodyType(ApiDescription endpoint) + { + var producesResponseTypeAttribute = endpoint.ActionDescriptor.GetFilterMetadata(); + ConsistencyGuard.ThrowIf(producesResponseTypeAttribute == null); + + ControllerParameterDescriptor? requestBodyDescriptor = endpoint.ActionDescriptor.GetBodyParameterDescriptor(); + Type bodyType = (requestBodyDescriptor?.ParameterType ?? producesResponseTypeAttribute.Type).ConstructedToOpenType(); + + if (bodyType == typeof(CollectionResponseDocument<>) && endpoint.ParameterDescriptions.Count > 0) + { + bodyType = typeof(SecondaryResponseDocument<>); + } + + return bodyType; + } + + private string ApplyTemplate(string openApiOperationIdTemplate, ResourceType? resourceType, ApiDescription endpoint) + { + ConsistencyGuard.ThrowIf(endpoint.RelativePath == null); + ConsistencyGuard.ThrowIf(endpoint.HttpMethod == null); + + string method = endpoint.HttpMethod.ToLowerInvariant(); + string relationshipName = openApiOperationIdTemplate.Contains("[RelationshipName]") ? endpoint.RelativePath.Split('/').Last() : string.Empty; + + // @formatter:wrap_chained_method_calls chop_always + // @formatter:wrap_before_first_method_call true + + string pascalCaseOpenApiOperationId = openApiOperationIdTemplate + .Replace("[Method]", method) + .Replace("[PrimaryResourceName]", resourceType?.PublicName.Singularize()) + .Replace("[RelationshipName]", relationshipName) + .Pascalize(); + + // @formatter:wrap_before_first_method_call true restore + // @formatter:wrap_chained_method_calls restore + + JsonNamingPolicy? namingPolicy = _options.SerializerOptions.PropertyNamingPolicy; + return namingPolicy != null ? namingPolicy.ConvertName(pascalCaseOpenApiOperationId) : pascalCaseOpenApiOperationId; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiResourceObjectConverter.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiResourceObjectConverter.cs new file mode 100644 index 0000000000..dd7c9503e7 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiResourceObjectConverter.cs @@ -0,0 +1,106 @@ +using System.Net; +using System.Text.Json; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Serialization.JsonConverters; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle; + +internal sealed class OpenApiResourceObjectConverter : ResourceObjectConverter +{ + private readonly IJsonApiRequestAccessor _requestAccessor; + + private bool HasOpenApiExtension + { + get + { + if (_requestAccessor.Current == null) + { + return false; + } + + return _requestAccessor.Current.Extensions.Contains(OpenApiMediaTypeExtension.OpenApi) || + _requestAccessor.Current.Extensions.Contains(OpenApiMediaTypeExtension.RelaxedOpenApi); + } + } + + public OpenApiResourceObjectConverter(IResourceGraph resourceGraph, IJsonApiRequestAccessor requestAccessor) + : base(resourceGraph) + { + ArgumentNullException.ThrowIfNull(requestAccessor); + + _requestAccessor = requestAccessor; + } + + private protected override void ValidateExtensionInAttributes(string extensionNamespace, string extensionName, ResourceType resourceType, + Utf8JsonReader reader) + { + if (IsOpenApiDiscriminator(extensionNamespace, extensionName)) + { + const string jsonPointer = $"attributes/{OpenApiMediaTypeExtension.ExtensionNamespace}:{OpenApiMediaTypeExtension.DiscriminatorPropertyName}"; + ValidateOpenApiDiscriminatorValue(resourceType, jsonPointer, reader); + } + else + { + base.ValidateExtensionInAttributes(extensionNamespace, extensionName, resourceType, reader); + } + } + + private protected override void ValidateExtensionInRelationships(string extensionNamespace, string extensionName, ResourceType resourceType, + Utf8JsonReader reader) + { + if (IsOpenApiDiscriminator(extensionNamespace, extensionName)) + { + const string jsonPointer = $"relationships/{OpenApiMediaTypeExtension.ExtensionNamespace}:{OpenApiMediaTypeExtension.DiscriminatorPropertyName}"; + ValidateOpenApiDiscriminatorValue(resourceType, jsonPointer, reader); + } + else + { + base.ValidateExtensionInRelationships(extensionNamespace, extensionName, resourceType, reader); + } + } + + private protected override void WriteExtensionInAttributes(Utf8JsonWriter writer, ResourceObject value) + { + if (HasOpenApiExtension) + { + writer.WriteString(OpenApiMediaTypeExtension.FullyQualifiedOpenApiDiscriminatorPropertyName, value.Type); + } + } + + private protected override void WriteExtensionInRelationships(Utf8JsonWriter writer, ResourceObject value) + { + if (HasOpenApiExtension) + { + writer.WriteString(OpenApiMediaTypeExtension.FullyQualifiedOpenApiDiscriminatorPropertyName, value.Type); + } + } + + private bool IsOpenApiDiscriminator(string extensionNamespace, string extensionName) + { + return HasOpenApiExtension && extensionNamespace == OpenApiMediaTypeExtension.ExtensionNamespace && + extensionName == OpenApiMediaTypeExtension.DiscriminatorPropertyName; + } + + private static void ValidateOpenApiDiscriminatorValue(ResourceType resourceType, string relativeJsonPointer, Utf8JsonReader reader) + { + string? discriminatorValue = reader.GetString(); + + if (discriminatorValue != resourceType.PublicName) + { + var jsonApiException = new JsonApiException(new ErrorObject(HttpStatusCode.Conflict) + { + Title = "Incompatible resource type found.", + Detail = + $"Expected {OpenApiMediaTypeExtension.FullyQualifiedOpenApiDiscriminatorPropertyName} with value '{resourceType.PublicName}' instead of '{discriminatorValue}'.", + Source = new ErrorSource + { + Pointer = relativeJsonPointer + } + }); + + CapturedThrow(jsonApiException); + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiSchemaExtensions.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiSchemaExtensions.cs new file mode 100644 index 0000000000..10095834b2 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/OpenApiSchemaExtensions.cs @@ -0,0 +1,48 @@ +using Microsoft.OpenApi.Models; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle; + +internal static class OpenApiSchemaExtensions +{ + public static void ReorderProperties(this OpenApiSchema fullSchema, IEnumerable propertyNamesInOrder) + { + ArgumentNullException.ThrowIfNull(fullSchema); + ArgumentNullException.ThrowIfNull(propertyNamesInOrder); + + var propertiesInOrder = new Dictionary(); + + foreach (string propertyName in propertyNamesInOrder) + { + if (fullSchema.Properties.TryGetValue(propertyName, out OpenApiSchema? schema)) + { + propertiesInOrder.Add(propertyName, schema); + } + } + + ConsistencyGuard.ThrowIf(fullSchema.Properties.Count != propertiesInOrder.Count); + + fullSchema.Properties = propertiesInOrder; + } + + public static OpenApiSchema WrapInExtendedSchema(this OpenApiSchema source) + { + ArgumentNullException.ThrowIfNull(source); + + return new OpenApiSchema + { + AllOf = [source] + }; + } + + public static OpenApiSchema UnwrapLastExtendedSchema(this OpenApiSchema source) + { + ArgumentNullException.ThrowIfNull(source); + + if (source.AllOf is { Count: > 0 }) + { + return source.AllOf.Last(); + } + + return source; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ParameterInfoWrapper.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ParameterInfoWrapper.cs new file mode 100644 index 0000000000..a785de0452 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ParameterInfoWrapper.cs @@ -0,0 +1,78 @@ +using System.Reflection; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle; + +/// +/// Used for parameters in action method expansion. Changes the parameter name and type, while still using all metadata of the underlying non-expanded +/// parameter. +/// +internal sealed class ParameterInfoWrapper : ParameterInfo +{ + private readonly ParameterInfo _innerParameter; + + public override ParameterAttributes Attributes => _innerParameter.Attributes; + public override IEnumerable CustomAttributes => _innerParameter.CustomAttributes; + public override object? DefaultValue => _innerParameter.DefaultValue; + public override bool HasDefaultValue => _innerParameter.HasDefaultValue; + public override MemberInfo Member => _innerParameter.Member; + public override int MetadataToken => _innerParameter.MetadataToken; + public override string? Name { get; } + public override Type ParameterType { get; } + public override int Position => _innerParameter.Position; + public override object? RawDefaultValue => _innerParameter.RawDefaultValue; + + public ParameterInfoWrapper(ParameterInfo innerParameter, Type overriddenParameterType, string? overriddenName) + { + ArgumentNullException.ThrowIfNull(innerParameter); + ArgumentNullException.ThrowIfNull(overriddenParameterType); + + _innerParameter = innerParameter; + ParameterType = overriddenParameterType; + Name = overriddenName; + } + + public override object[] GetCustomAttributes(bool inherit) + { + return _innerParameter.GetCustomAttributes(inherit); + } + + public override object[] GetCustomAttributes(Type attributeType, bool inherit) + { + return _innerParameter.GetCustomAttributes(attributeType, inherit); + } + + public override bool IsDefined(Type attributeType, bool inherit) + { + return _innerParameter.IsDefined(attributeType, inherit); + } + + public override bool Equals(object? obj) + { + return _innerParameter.Equals(obj); + } + + public override int GetHashCode() + { + return _innerParameter.GetHashCode(); + } + + public override string ToString() + { + return _innerParameter.ToString(); + } + + public override IList GetCustomAttributesData() + { + return _innerParameter.GetCustomAttributesData(); + } + + public override Type[] GetOptionalCustomModifiers() + { + return _innerParameter.GetOptionalCustomModifiers(); + } + + public override Type[] GetRequiredCustomModifiers() + { + return _innerParameter.GetRequiredCustomModifiers(); + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ResourceFieldValidationMetadataProvider.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ResourceFieldValidationMetadataProvider.cs new file mode 100644 index 0000000000..f0ce60c730 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ResourceFieldValidationMetadataProvider.cs @@ -0,0 +1,81 @@ +using System.ComponentModel.DataAnnotations; +using System.Reflection; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle; + +internal sealed class ResourceFieldValidationMetadataProvider +{ + private readonly IJsonApiOptions _options; + private readonly IModelMetadataProvider _modelMetadataProvider; + + public ResourceFieldValidationMetadataProvider(IJsonApiOptions options, IModelMetadataProvider modelMetadataProvider) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(modelMetadataProvider); + + _options = options; + _modelMetadataProvider = modelMetadataProvider; + } + + public bool IsNullable(ResourceFieldAttribute field) + { + ArgumentNullException.ThrowIfNull(field); + + if (field is HasManyAttribute) + { + return false; + } + + bool hasRequiredAttribute = field.Property.HasAttribute(); + + if (_options.ValidateModelState && hasRequiredAttribute) + { + return false; + } + + NullabilityInfoContext nullabilityContext = new(); + NullabilityInfo nullabilityInfo = nullabilityContext.Create(field.Property); + return nullabilityInfo.ReadState != NullabilityState.NotNull; + } + + public bool IsRequired(ResourceFieldAttribute field) + { + ArgumentNullException.ThrowIfNull(field); + + bool hasRequiredAttribute = field.Property.HasAttribute(); + + if (!_options.ValidateModelState) + { + return hasRequiredAttribute; + } + + if (field is HasManyAttribute) + { + return false; + } + + NullabilityInfoContext nullabilityContext = new(); + NullabilityInfo nullabilityInfo = nullabilityContext.Create(field.Property); + bool isRequiredValueType = field.Property.PropertyType.IsValueType && hasRequiredAttribute && nullabilityInfo.ReadState == NullabilityState.NotNull; + + if (isRequiredValueType) + { + // Special case: ASP.NET ModelState Validation effectively ignores value types with [Required]. + return false; + } + + return IsModelStateValidationRequired(field); + } + + private bool IsModelStateValidationRequired(ResourceFieldAttribute field) + { + ModelMetadata modelMetadata = _modelMetadataProvider.GetMetadataForProperty(field.Type.ClrType, field.Property.Name); + + // Non-nullable reference types are implicitly required, unless SuppressImplicitRequiredAttributeForNonNullableReferenceTypes is set. + return modelMetadata.ValidatorMetadata.Any(validatorMetadata => validatorMetadata is RequiredAttribute); + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerationTracer.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerationTracer.cs new file mode 100644 index 0000000000..6dbd6bb2f3 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerationTracer.cs @@ -0,0 +1,153 @@ +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle; + +/// +/// Enables to log recursive component schema generation at trace level. +/// +internal sealed partial class SchemaGenerationTracer +{ + private readonly ILoggerFactory _loggerFactory; + + public SchemaGenerationTracer(ILoggerFactory loggerFactory) + { + ArgumentNullException.ThrowIfNull(loggerFactory); + + _loggerFactory = loggerFactory; + } + + public ISchemaGenerationTraceScope TraceStart(object generator) + { + ArgumentNullException.ThrowIfNull(generator); + + return InnerTraceStart(generator, () => "(none)"); + } + + public ISchemaGenerationTraceScope TraceStart(object generator, Type schemaType) + { + ArgumentNullException.ThrowIfNull(generator); + ArgumentNullException.ThrowIfNull(schemaType); + + return InnerTraceStart(generator, () => GetSchemaTypeName(schemaType)); + } + + public ISchemaGenerationTraceScope TraceStart(object generator, AtomicOperationCode operationCode) + { + ArgumentNullException.ThrowIfNull(generator); + + return InnerTraceStart(generator, () => $"{nameof(AtomicOperationCode)}.{operationCode}"); + } + + public ISchemaGenerationTraceScope TraceStart(object generator, RelationshipAttribute relationship) + { + ArgumentNullException.ThrowIfNull(generator); + ArgumentNullException.ThrowIfNull(relationship); + + return InnerTraceStart(generator, + () => $"{GetSchemaTypeName(relationship.GetType())}({GetSchemaTypeName(relationship.LeftType.ClrType)}.{relationship.Property.Name})"); + } + + public ISchemaGenerationTraceScope TraceStart(object generator, Type schemaOpenType, RelationshipAttribute relationship) + { + ArgumentNullException.ThrowIfNull(generator); + ArgumentNullException.ThrowIfNull(schemaOpenType); + ArgumentNullException.ThrowIfNull(relationship); + + return InnerTraceStart(generator, + () => + $"{GetSchemaTypeName(schemaOpenType)} with {GetSchemaTypeName(relationship.GetType())}({GetSchemaTypeName(relationship.LeftType.ClrType)}.{relationship.Property.Name})"); + } + + private ISchemaGenerationTraceScope InnerTraceStart(object generator, Func getSchemaTypeName) + { + ILogger logger = _loggerFactory.CreateLogger(generator.GetType()); + + if (logger.IsEnabled(LogLevel.Trace)) + { + string schemaTypeName = getSchemaTypeName(); + return new SchemaGenerationTraceScope(logger, schemaTypeName); + } + + return DisabledSchemaGenerationTraceScope.Instance; + } + + private static string GetSchemaTypeName(Type type) + { + if (type.IsConstructedGenericType) + { + string typeArguments = string.Join(',', type.GetGenericArguments().Select(GetSchemaTypeName)); + int arityIndex = type.Name.IndexOf('`'); + return $"{type.Name[..arityIndex]}<{typeArguments}>"; + } + + return type.Name; + } + + private sealed partial class SchemaGenerationTraceScope : ISchemaGenerationTraceScope + { + private static readonly AsyncLocal RecursionDepthAsyncLocal = new(); + + private readonly ILogger _logger; + private readonly string _schemaTypeName; + private string? _schemaId; + + public SchemaGenerationTraceScope(ILogger logger, string schemaTypeName) + { + ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(schemaTypeName); + + _logger = logger; + _schemaTypeName = schemaTypeName; + + RecursionDepthAsyncLocal.Value++; + LogStarted(RecursionDepthAsyncLocal.Value, _schemaTypeName); + } + + public void TraceSucceeded(string schemaId) + { + _schemaId = schemaId; + } + + public void Dispose() + { + if (_schemaId != null) + { + LogSucceeded(RecursionDepthAsyncLocal.Value, _schemaTypeName, _schemaId); + } + else + { + LogFailed(RecursionDepthAsyncLocal.Value, _schemaTypeName); + } + + RecursionDepthAsyncLocal.Value--; + } + + [LoggerMessage(Level = LogLevel.Trace, SkipEnabledCheck = true, Message = "({Depth:D2}) Started for {SchemaTypeName}.")] + private partial void LogStarted(int depth, string schemaTypeName); + + [LoggerMessage(Level = LogLevel.Trace, SkipEnabledCheck = true, Message = "({Depth:D2}) Generated '{SchemaId}' from {SchemaTypeName}.")] + private partial void LogSucceeded(int depth, string schemaTypeName, string schemaId); + + [LoggerMessage(Level = LogLevel.Trace, SkipEnabledCheck = true, Message = "({Depth:D2}) Failed for {SchemaTypeName}.")] + private partial void LogFailed(int depth, string schemaTypeName); + } + + private sealed class DisabledSchemaGenerationTraceScope : ISchemaGenerationTraceScope + { + public static DisabledSchemaGenerationTraceScope Instance { get; } = new(); + + private DisabledSchemaGenerationTraceScope() + { + } + + public void TraceSucceeded(string schemaId) + { + } + + public void Dispose() + { + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Components/AtomicOperationCodeSchemaGenerator.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Components/AtomicOperationCodeSchemaGenerator.cs new file mode 100644 index 0000000000..78a25da441 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Components/AtomicOperationCodeSchemaGenerator.cs @@ -0,0 +1,55 @@ +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.SchemaGenerators.Components; + +internal sealed class AtomicOperationCodeSchemaGenerator +{ + private readonly SchemaGenerationTracer _schemaGenerationTracer; + private readonly JsonApiSchemaIdSelector _schemaIdSelector; + + public AtomicOperationCodeSchemaGenerator(SchemaGenerationTracer schemaGenerationTracer, JsonApiSchemaIdSelector schemaIdSelector) + { + ArgumentNullException.ThrowIfNull(schemaGenerationTracer); + ArgumentNullException.ThrowIfNull(schemaIdSelector); + + _schemaGenerationTracer = schemaGenerationTracer; + _schemaIdSelector = schemaIdSelector; + } + + public OpenApiSchema GenerateSchema(AtomicOperationCode operationCode, SchemaRepository schemaRepository) + { + ArgumentNullException.ThrowIfNull(schemaRepository); + + string schemaId = _schemaIdSelector.GetAtomicOperationCodeSchemaId(operationCode); + + if (schemaRepository.Schemas.ContainsKey(schemaId)) + { + return new OpenApiSchema + { + Reference = new OpenApiReference + { + Id = schemaId, + Type = ReferenceType.Schema + } + }; + } + + using ISchemaGenerationTraceScope traceScope = _schemaGenerationTracer.TraceStart(this, operationCode); + + string enumValue = operationCode.ToString().ToLowerInvariant(); + + var fullSchema = new OpenApiSchema + { + Type = "string", + Enum = [new OpenApiString(enumValue)] + }; + + OpenApiSchema referenceSchema = schemaRepository.AddDefinition(schemaId, fullSchema); + + traceScope.TraceSucceeded(schemaId); + return referenceSchema; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Components/DataContainerSchemaGenerator.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Components/DataContainerSchemaGenerator.cs new file mode 100644 index 0000000000..c4b41dd0f5 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Components/DataContainerSchemaGenerator.cs @@ -0,0 +1,109 @@ +using System.Reflection; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.OpenApi.Swashbuckle.SwaggerComponents; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.SchemaGenerators.Components; + +/// +/// Generates the reference schema for the Data property in a request or response schema, taking schema inheritance into account. +/// +internal sealed class DataContainerSchemaGenerator +{ + private readonly SchemaGenerationTracer _schemaGenerationTracer; + private readonly DataSchemaGenerator _dataSchemaGenerator; + private readonly IResourceGraph _resourceGraph; + + public DataContainerSchemaGenerator(SchemaGenerationTracer schemaGenerationTracer, DataSchemaGenerator dataSchemaGenerator, IResourceGraph resourceGraph) + { + ArgumentNullException.ThrowIfNull(schemaGenerationTracer); + ArgumentNullException.ThrowIfNull(dataSchemaGenerator); + ArgumentNullException.ThrowIfNull(resourceGraph); + + _schemaGenerationTracer = schemaGenerationTracer; + _dataSchemaGenerator = dataSchemaGenerator; + _resourceGraph = resourceGraph; + } + + public OpenApiSchema GenerateSchemaForCommonResourceDataInResponse(SchemaRepository schemaRepository) + { + ArgumentNullException.ThrowIfNull(schemaRepository); + + return _dataSchemaGenerator.GenerateSchemaForCommonData(typeof(ResourceInResponse), schemaRepository); + } + + public OpenApiSchema GenerateSchema(Type dataContainerSchemaType, ResourceType resourceType, bool forRequestSchema, bool canIncludeRelated, + SchemaRepository schemaRepository) + { + ArgumentNullException.ThrowIfNull(dataContainerSchemaType); + ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(schemaRepository); + + if (schemaRepository.TryLookupByType(dataContainerSchemaType, out OpenApiSchema referenceSchemaForData)) + { + return referenceSchemaForData; + } + + Type dataConstructedType = GetElementTypeOfDataProperty(dataContainerSchemaType, resourceType); + + if (schemaRepository.TryLookupByType(dataConstructedType, out _)) + { + return referenceSchemaForData; + } + + using ISchemaGenerationTraceScope traceScope = _schemaGenerationTracer.TraceStart(this, dataConstructedType); + + if (canIncludeRelated) + { + var resourceSchemaType = ResourceSchemaType.Create(dataConstructedType, _resourceGraph); + + if (resourceSchemaType.SchemaOpenType == typeof(DataInResponse<>)) + { + // Ensure all reachable related resource types in response schemas are generated upfront. + // This is needed to make includes work when not all endpoints are exposed. + GenerateReachableRelatedTypesInResponse(dataConstructedType, schemaRepository); + } + } + + referenceSchemaForData = _dataSchemaGenerator.GenerateSchema(dataConstructedType, forRequestSchema, schemaRepository); + traceScope.TraceSucceeded(referenceSchemaForData.Reference.Id); + return referenceSchemaForData; + } + + private static Type GetElementTypeOfDataProperty(Type dataContainerConstructedType, ResourceType resourceType) + { + PropertyInfo? dataProperty = dataContainerConstructedType.GetProperty("Data"); + ConsistencyGuard.ThrowIf(dataProperty == null); + + Type innerPropertyType = dataProperty.PropertyType.ConstructedToOpenType().IsAssignableTo(typeof(ICollection<>)) + ? dataProperty.PropertyType.GenericTypeArguments[0] + : dataProperty.PropertyType; + + if (innerPropertyType == typeof(ResourceInResponse)) + { + return typeof(DataInResponse<>).MakeGenericType(resourceType.ClrType); + } + + ConsistencyGuard.ThrowIf(!innerPropertyType.IsGenericType); + + return innerPropertyType; + } + + private void GenerateReachableRelatedTypesInResponse(Type dataConstructedType, SchemaRepository schemaRepository) + { + Type dataOpenType = dataConstructedType.GetGenericTypeDefinition(); + + if (dataOpenType == typeof(DataInResponse<>)) + { + var resourceSchemaType = ResourceSchemaType.Create(dataConstructedType, _resourceGraph); + + foreach (ResourceType relatedType in IncludeDependencyScanner.Instance.GetReachableRelatedTypes(resourceSchemaType.ResourceType)) + { + Type resourceDataConstructedType = typeof(DataInResponse<>).MakeGenericType(relatedType.ClrType); + _ = _dataSchemaGenerator.GenerateSchema(resourceDataConstructedType, false, schemaRepository); + } + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Components/DataSchemaGenerator.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Components/DataSchemaGenerator.cs new file mode 100644 index 0000000000..4d7783f780 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Components/DataSchemaGenerator.cs @@ -0,0 +1,641 @@ +using System.Collections.Concurrent; +using System.Reflection; +using System.Runtime.CompilerServices; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.OpenApi.Swashbuckle.SwaggerComponents; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.SchemaGenerators.Components; + +internal sealed class DataSchemaGenerator +{ + // Workaround for bug at https://github.com/microsoft/kiota/issues/2432#issuecomment-2436625836. + private static readonly bool RepeatDiscriminatorInResponseDerivedTypes = bool.Parse(bool.TrueString); + + private static readonly ConcurrentDictionary UltimateBaseResourceTypeCache = []; + + private static readonly string[] DataPropertyNamesInOrder = + [ + JsonApiPropertyName.Type, + JsonApiPropertyName.Id, + JsonApiPropertyName.Lid, + JsonApiPropertyName.Attributes, + JsonApiPropertyName.Relationships, + JsonApiPropertyName.Links, + JsonApiPropertyName.Meta + ]; + + private readonly SchemaGenerationTracer _schemaGenerationTracer; + private readonly SchemaGenerator _defaultSchemaGenerator; + private readonly GenerationCacheSchemaGenerator _generationCacheSchemaGenerator; + private readonly ResourceTypeSchemaGenerator _resourceTypeSchemaGenerator; + private readonly ResourceIdSchemaGenerator _resourceIdSchemaGenerator; + private readonly LinksVisibilitySchemaGenerator _linksVisibilitySchemaGenerator; + private readonly MetaSchemaGenerator _metaSchemaGenerator; + private readonly JsonApiSchemaIdSelector _schemaIdSelector; + private readonly IJsonApiOptions _options; + private readonly IResourceGraph _resourceGraph; + private readonly ResourceFieldValidationMetadataProvider _resourceFieldValidationMetadataProvider; + private readonly RelationshipTypeFactory _relationshipTypeFactory; + private readonly ResourceDocumentationReader _resourceDocumentationReader; + + public DataSchemaGenerator(SchemaGenerationTracer schemaGenerationTracer, SchemaGenerator defaultSchemaGenerator, + GenerationCacheSchemaGenerator generationCacheSchemaGenerator, ResourceTypeSchemaGenerator resourceTypeSchemaGenerator, + ResourceIdSchemaGenerator resourceIdSchemaGenerator, LinksVisibilitySchemaGenerator linksVisibilitySchemaGenerator, + MetaSchemaGenerator metaSchemaGenerator, JsonApiSchemaIdSelector schemaIdSelector, IJsonApiOptions options, IResourceGraph resourceGraph, + ResourceFieldValidationMetadataProvider resourceFieldValidationMetadataProvider, RelationshipTypeFactory relationshipTypeFactory, + ResourceDocumentationReader resourceDocumentationReader) + { + ArgumentNullException.ThrowIfNull(schemaGenerationTracer); + ArgumentNullException.ThrowIfNull(defaultSchemaGenerator); + ArgumentNullException.ThrowIfNull(generationCacheSchemaGenerator); + ArgumentNullException.ThrowIfNull(resourceTypeSchemaGenerator); + ArgumentNullException.ThrowIfNull(resourceIdSchemaGenerator); + ArgumentNullException.ThrowIfNull(linksVisibilitySchemaGenerator); + ArgumentNullException.ThrowIfNull(metaSchemaGenerator); + ArgumentNullException.ThrowIfNull(schemaIdSelector); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(resourceGraph); + ArgumentNullException.ThrowIfNull(resourceFieldValidationMetadataProvider); + ArgumentNullException.ThrowIfNull(relationshipTypeFactory); + ArgumentNullException.ThrowIfNull(resourceDocumentationReader); + + _schemaGenerationTracer = schemaGenerationTracer; + _defaultSchemaGenerator = defaultSchemaGenerator; + _generationCacheSchemaGenerator = generationCacheSchemaGenerator; + _resourceTypeSchemaGenerator = resourceTypeSchemaGenerator; + _resourceIdSchemaGenerator = resourceIdSchemaGenerator; + _linksVisibilitySchemaGenerator = linksVisibilitySchemaGenerator; + _metaSchemaGenerator = metaSchemaGenerator; + _schemaIdSelector = schemaIdSelector; + _options = options; + _resourceGraph = resourceGraph; + _resourceFieldValidationMetadataProvider = resourceFieldValidationMetadataProvider; + _relationshipTypeFactory = relationshipTypeFactory; + _resourceDocumentationReader = resourceDocumentationReader; + } + + public OpenApiSchema GenerateSchema(Type dataSchemaType, bool forRequestSchema, SchemaRepository schemaRepository) + { + // For a given resource (identifier) type, we always generate the full type hierarchy. Discriminator mappings + // are managed manually, because there's no way to intercept in the Swashbuckle recursive component schema generation. + + ArgumentNullException.ThrowIfNull(dataSchemaType); + ArgumentNullException.ThrowIfNull(schemaRepository); + + if (schemaRepository.TryLookupByType(dataSchemaType, out OpenApiSchema referenceSchemaForData)) + { + return referenceSchemaForData; + } + + var resourceSchemaType = ResourceSchemaType.Create(dataSchemaType, _resourceGraph); + ResourceType resourceType = resourceSchemaType.ResourceType; + + Type? commonDataSchemaType = GetCommonSchemaType(resourceSchemaType.SchemaOpenType); + + if (commonDataSchemaType != null) + { + _ = GenerateSchemaForCommonData(commonDataSchemaType, schemaRepository); + } + + if (resourceType.BaseType != null) + { + ResourceType ultimateBaseResourceType = GetUltimateBaseType(resourceType); + Type ultimateBaseSchemaType = ChangeResourceTypeInSchemaType(dataSchemaType, ultimateBaseResourceType); + + _ = GenerateSchema(ultimateBaseSchemaType, forRequestSchema, schemaRepository); + + return schemaRepository.LookupByType(dataSchemaType); + } + + using ISchemaGenerationTraceScope traceScope = _schemaGenerationTracer.TraceStart(this, dataSchemaType); + + referenceSchemaForData = _defaultSchemaGenerator.GenerateSchema(dataSchemaType, schemaRepository); + OpenApiSchema fullSchemaForData = schemaRepository.Schemas[referenceSchemaForData.Reference.Id]; + fullSchemaForData.AdditionalPropertiesAllowed = false; + + OpenApiSchema inlineSchemaForData = fullSchemaForData.UnwrapLastExtendedSchema(); + + SetAbstract(inlineSchemaForData, resourceSchemaType); + SetResourceType(inlineSchemaForData, resourceType, schemaRepository); + AdaptResourceIdentity(inlineSchemaForData, resourceSchemaType, forRequestSchema, schemaRepository); + SetResourceId(inlineSchemaForData, resourceType, schemaRepository); + SetResourceFields(inlineSchemaForData, resourceSchemaType, forRequestSchema, schemaRepository); + SetDocumentation(fullSchemaForData, resourceType); + SetLinksVisibility(inlineSchemaForData, resourceSchemaType, schemaRepository); + + if (resourceType.IsPartOfTypeHierarchy()) + { + GenerateDataSchemasForDirectlyDerivedTypes(resourceSchemaType, forRequestSchema, schemaRepository); + } + + inlineSchemaForData.ReorderProperties(DataPropertyNamesInOrder); + + if (commonDataSchemaType != null) + { + MapInDiscriminator(resourceSchemaType, forRequestSchema, JsonApiPropertyName.Type, schemaRepository); + } + + if (RequiresRootObjectTypeInDataSchema(resourceSchemaType, forRequestSchema)) + { + fullSchemaForData.Extensions[SetSchemaTypeToObjectDocumentFilter.RequiresRootObjectTypeKey] = new OpenApiBoolean(true); + } + + traceScope.TraceSucceeded(referenceSchemaForData.Reference.Id); + return referenceSchemaForData; + } + + private static Type? GetCommonSchemaType(Type schemaOpenType) + { + StrongBox? boxedSchemaType = null; + + if (schemaOpenType == typeof(IdentifierInRequest<>)) + { + boxedSchemaType = new StrongBox(typeof(IdentifierInRequest)); + } + else if (schemaOpenType == typeof(DataInCreateRequest<>)) + { + boxedSchemaType = new StrongBox(typeof(ResourceInCreateRequest)); + } + else if (schemaOpenType == typeof(AttributesInCreateRequest<>)) + { + boxedSchemaType = new StrongBox(typeof(AttributesInCreateRequest)); + } + else if (schemaOpenType == typeof(RelationshipsInCreateRequest<>)) + { + boxedSchemaType = new StrongBox(typeof(RelationshipsInCreateRequest)); + } + else if (schemaOpenType == typeof(DataInUpdateRequest<>)) + { + boxedSchemaType = new StrongBox(typeof(ResourceInUpdateRequest)); + } + else if (schemaOpenType == typeof(AttributesInUpdateRequest<>)) + { + boxedSchemaType = new StrongBox(typeof(AttributesInUpdateRequest)); + } + else if (schemaOpenType == typeof(RelationshipsInUpdateRequest<>)) + { + boxedSchemaType = new StrongBox(typeof(RelationshipsInUpdateRequest)); + } + else if (schemaOpenType == typeof(IdentifierInResponse<>)) + { + boxedSchemaType = new StrongBox(null); + } + else if (schemaOpenType == typeof(DataInResponse<>)) + { + boxedSchemaType = new StrongBox(typeof(ResourceInResponse)); + } + else if (schemaOpenType == typeof(AttributesInResponse<>)) + { + boxedSchemaType = new StrongBox(typeof(AttributesInResponse)); + } + else if (schemaOpenType == typeof(RelationshipsInResponse<>)) + { + boxedSchemaType = new StrongBox(typeof(RelationshipsInResponse)); + } + + ConsistencyGuard.ThrowIf(boxedSchemaType == null); + return boxedSchemaType.Value; + } + + public OpenApiSchema GenerateSchemaForCommonData(Type commonDataSchemaType, SchemaRepository schemaRepository) + { + ArgumentNullException.ThrowIfNull(commonDataSchemaType); + ArgumentNullException.ThrowIfNull(schemaRepository); + + if (schemaRepository.TryLookupByType(commonDataSchemaType, out OpenApiSchema? referenceSchema)) + { + return referenceSchema; + } + + using ISchemaGenerationTraceScope traceScope = _schemaGenerationTracer.TraceStart(this, commonDataSchemaType); + + OpenApiSchema referenceSchemaForResourceType = _resourceTypeSchemaGenerator.GenerateSchema(schemaRepository); + OpenApiSchema referenceSchemaForMeta = _metaSchemaGenerator.GenerateSchema(schemaRepository); + + var fullSchema = new OpenApiSchema + { + Type = "object", + Required = new SortedSet([JsonApiPropertyName.Type]), + Properties = new Dictionary + { + [JsonApiPropertyName.Type] = referenceSchemaForResourceType.WrapInExtendedSchema(), + [referenceSchemaForMeta.Reference.Id] = referenceSchemaForMeta.WrapInExtendedSchema() + }, + AdditionalPropertiesAllowed = false, + Discriminator = new OpenApiDiscriminator + { + PropertyName = JsonApiPropertyName.Type, + Mapping = new SortedDictionary(StringComparer.Ordinal) + }, + Extensions = + { + ["x-abstract"] = new OpenApiBoolean(true) + } + }; + + string schemaId = _schemaIdSelector.GetSchemaId(commonDataSchemaType); + + referenceSchema = schemaRepository.AddDefinition(schemaId, fullSchema); + schemaRepository.RegisterType(commonDataSchemaType, schemaId); + + traceScope.TraceSucceeded(schemaId); + return referenceSchema; + } + + private static ResourceType GetUltimateBaseType(ResourceType resourceType) + { + return UltimateBaseResourceTypeCache.GetOrAdd(resourceType, type => + { + ResourceType baseType = type; + + while (baseType.BaseType != null) + { + baseType = baseType.BaseType; + } + + return baseType; + }); + } + + private static Type ChangeResourceTypeInSchemaType(Type schemaType, ResourceType resourceType) + { + Type schemaOpenType = schemaType.ConstructedToOpenType(); + return schemaOpenType.MakeGenericType(resourceType.ClrType); + } + + private static void SetAbstract(OpenApiSchema fullSchema, ResourceSchemaType resourceSchemaType) + { + if (resourceSchemaType.ResourceType.ClrType.IsAbstract && resourceSchemaType.SchemaOpenType != typeof(IdentifierInRequest<>)) + { + fullSchema.Extensions["x-abstract"] = new OpenApiBoolean(true); + } + } + + private void SetResourceType(OpenApiSchema fullSchema, ResourceType resourceType, SchemaRepository schemaRepository) + { + if (fullSchema.Properties.ContainsKey(JsonApiPropertyName.Type)) + { + OpenApiSchema referenceSchema = _resourceTypeSchemaGenerator.GenerateSchema(resourceType, schemaRepository); + fullSchema.Properties[JsonApiPropertyName.Type] = referenceSchema.WrapInExtendedSchema(); + } + } + + private void AdaptResourceIdentity(OpenApiSchema fullSchema, ResourceSchemaType resourceSchemaType, bool forRequestSchema, + SchemaRepository schemaRepository) + { + if (!forRequestSchema) + { + return; + } + + bool hasAtomicOperationsEndpoint = _generationCacheSchemaGenerator.HasAtomicOperationsEndpoint(schemaRepository); + + if (!hasAtomicOperationsEndpoint) + { + fullSchema.Properties.Remove(JsonApiPropertyName.Lid); + } + + if (resourceSchemaType.SchemaOpenType == typeof(DataInCreateRequest<>)) + { + ClientIdGenerationMode clientIdGeneration = resourceSchemaType.ResourceType.ClientIdGeneration ?? _options.ClientIdGeneration; + + if (hasAtomicOperationsEndpoint) + { + if (clientIdGeneration == ClientIdGenerationMode.Forbidden) + { + fullSchema.Properties.Remove(JsonApiPropertyName.Id); + } + else if (clientIdGeneration == ClientIdGenerationMode.Required) + { + fullSchema.Properties.Remove(JsonApiPropertyName.Lid); + fullSchema.Required.Add(JsonApiPropertyName.Id); + } + } + else + { + if (clientIdGeneration == ClientIdGenerationMode.Forbidden) + { + fullSchema.Properties.Remove(JsonApiPropertyName.Id); + } + else if (clientIdGeneration == ClientIdGenerationMode.Required) + { + fullSchema.Required.Add(JsonApiPropertyName.Id); + } + } + } + else + { + if (!hasAtomicOperationsEndpoint) + { + fullSchema.Required.Add(JsonApiPropertyName.Id); + } + } + } + + private void SetResourceId(OpenApiSchema fullSchema, ResourceType resourceType, SchemaRepository schemaRepository) + { + if (fullSchema.Properties.ContainsKey(JsonApiPropertyName.Id)) + { + OpenApiSchema idSchema = _resourceIdSchemaGenerator.GenerateSchema(resourceType, schemaRepository); + fullSchema.Properties[JsonApiPropertyName.Id] = idSchema; + } + } + + private void SetResourceFields(OpenApiSchema fullSchemaForData, ResourceSchemaType resourceSchemaType, bool forRequestSchema, + SchemaRepository schemaRepository) + { + bool schemaHasFields = fullSchemaForData.Properties.ContainsKey(JsonApiPropertyName.Attributes) && + fullSchemaForData.Properties.ContainsKey(JsonApiPropertyName.Relationships); + + if (schemaHasFields) + { + var fieldSchemaBuilder = new ResourceFieldSchemaBuilder(_schemaGenerationTracer, _defaultSchemaGenerator, this, _linksVisibilitySchemaGenerator, + _resourceFieldValidationMetadataProvider, _relationshipTypeFactory, resourceSchemaType); + + SetFieldSchemaMembers(fullSchemaForData, resourceSchemaType, forRequestSchema, true, fieldSchemaBuilder, schemaRepository); + SetFieldSchemaMembers(fullSchemaForData, resourceSchemaType, forRequestSchema, false, fieldSchemaBuilder, schemaRepository); + } + } + + private void SetFieldSchemaMembers(OpenApiSchema fullSchemaForData, ResourceSchemaType resourceSchemaTypeForData, bool forRequestSchema, bool forAttributes, + ResourceFieldSchemaBuilder fieldSchemaBuilder, SchemaRepository schemaRepository) + { + string propertyNameInSchema = forAttributes ? JsonApiPropertyName.Attributes : JsonApiPropertyName.Relationships; + + OpenApiSchema referenceSchemaForFields = fullSchemaForData.Properties[propertyNameInSchema].UnwrapLastExtendedSchema(); + OpenApiSchema fullSchemaForFields = schemaRepository.Schemas[referenceSchemaForFields.Reference.Id]; + fullSchemaForFields.AdditionalPropertiesAllowed = false; + + SetAbstract(fullSchemaForFields, resourceSchemaTypeForData); + + if (forAttributes) + { + fieldSchemaBuilder.SetMembersOfAttributes(fullSchemaForFields, forRequestSchema, schemaRepository); + } + else + { + fieldSchemaBuilder.SetMembersOfRelationships(fullSchemaForFields, forRequestSchema, schemaRepository); + } + + if (fullSchemaForFields.Properties.Count == 0 && !resourceSchemaTypeForData.ResourceType.IsPartOfTypeHierarchy()) + { + fullSchemaForData.Properties.Remove(propertyNameInSchema); + schemaRepository.Schemas.Remove(referenceSchemaForFields.Reference.Id); + } + else + { + ResourceSchemaType resourceSchemaTypeForFields = + GetResourceSchemaTypeForFieldsProperty(resourceSchemaTypeForData, forAttributes ? "Attributes" : "Relationships"); + + Type? commonFieldsSchemaType = GetCommonSchemaType(resourceSchemaTypeForFields.SchemaOpenType); + ConsistencyGuard.ThrowIf(commonFieldsSchemaType == null); + + _ = GenerateSchemaForCommonFields(commonFieldsSchemaType, schemaRepository); + + MapInDiscriminator(resourceSchemaTypeForFields, forRequestSchema, OpenApiMediaTypeExtension.FullyQualifiedOpenApiDiscriminatorPropertyName, + schemaRepository); + + Type baseSchemaType; + + if (resourceSchemaTypeForFields.ResourceType.BaseType != null) + { + ResourceSchemaType resourceSchemaTypeForBase = + resourceSchemaTypeForFields.ChangeResourceType(resourceSchemaTypeForFields.ResourceType.BaseType); + + baseSchemaType = resourceSchemaTypeForBase.SchemaConstructedType; + } + else + { + baseSchemaType = commonFieldsSchemaType; + } + + OpenApiSchema referenceSchemaForBase = schemaRepository.LookupByType(baseSchemaType); + + schemaRepository.Schemas[referenceSchemaForFields.Reference.Id] = new OpenApiSchema + { + AllOf = + [ + referenceSchemaForBase, + fullSchemaForFields + ], + AdditionalPropertiesAllowed = false + }; + } + } + + private ResourceSchemaType GetResourceSchemaTypeForFieldsProperty(ResourceSchemaType resourceSchemaTypeForData, string propertyName) + { + PropertyInfo? fieldsProperty = resourceSchemaTypeForData.SchemaConstructedType.GetProperty(propertyName); + ConsistencyGuard.ThrowIf(fieldsProperty == null); + + Type fieldsConstructedType = fieldsProperty.PropertyType; + return ResourceSchemaType.Create(fieldsConstructedType, _resourceGraph); + } + + private OpenApiSchema GenerateSchemaForCommonFields(Type commonFieldsSchemaType, SchemaRepository schemaRepository) + { + if (schemaRepository.TryLookupByType(commonFieldsSchemaType, out OpenApiSchema? referenceSchema)) + { + return referenceSchema; + } + + using ISchemaGenerationTraceScope traceScope = _schemaGenerationTracer.TraceStart(this, commonFieldsSchemaType); + + OpenApiSchema referenceSchemaForResourceType = _resourceTypeSchemaGenerator.GenerateSchema(schemaRepository); + + var fullSchema = new OpenApiSchema + { + Type = "object", + Required = new SortedSet([OpenApiMediaTypeExtension.FullyQualifiedOpenApiDiscriminatorPropertyName]), + Properties = new Dictionary + { + [OpenApiMediaTypeExtension.FullyQualifiedOpenApiDiscriminatorPropertyName] = referenceSchemaForResourceType.WrapInExtendedSchema() + }, + AdditionalPropertiesAllowed = false, + Discriminator = new OpenApiDiscriminator + { + PropertyName = OpenApiMediaTypeExtension.FullyQualifiedOpenApiDiscriminatorPropertyName, + Mapping = new SortedDictionary(StringComparer.Ordinal) + }, + Extensions = + { + ["x-abstract"] = new OpenApiBoolean(true) + } + }; + + string schemaId = _schemaIdSelector.GetSchemaId(commonFieldsSchemaType); + + referenceSchema = schemaRepository.AddDefinition(schemaId, fullSchema); + schemaRepository.RegisterType(commonFieldsSchemaType, schemaId); + + traceScope.TraceSucceeded(schemaId); + return referenceSchema; + } + + private void MapInDiscriminator(ResourceSchemaType resourceSchemaType, bool forRequestSchema, string discriminatorPropertyName, + SchemaRepository schemaRepository) + { + OpenApiSchema referenceSchemaForDerived = schemaRepository.LookupByType(resourceSchemaType.SchemaConstructedType); + + foreach (ResourceType? baseResourceType in GetBaseTypesToMapInto(resourceSchemaType, forRequestSchema)) + { + Type baseSchemaType = baseResourceType == null + ? GetCommonSchemaType(resourceSchemaType.SchemaOpenType)! + : resourceSchemaType.ChangeResourceType(baseResourceType).SchemaConstructedType; + + OpenApiSchema referenceSchemaForBase = schemaRepository.LookupByType(baseSchemaType); + OpenApiSchema inlineSchemaForBase = schemaRepository.Schemas[referenceSchemaForBase.Reference.Id].UnwrapLastExtendedSchema(); + + inlineSchemaForBase.Discriminator ??= new OpenApiDiscriminator + { + PropertyName = discriminatorPropertyName, + Mapping = new SortedDictionary(StringComparer.Ordinal) + }; + + if (RepeatDiscriminatorInResponseDerivedTypes && !forRequestSchema) + { + inlineSchemaForBase.Required.Add(discriminatorPropertyName); + } + + string publicName = resourceSchemaType.ResourceType.PublicName; + + if (inlineSchemaForBase.Discriminator.Mapping.TryAdd(publicName, referenceSchemaForDerived.Reference.ReferenceV3) && baseResourceType == null) + { + MapResourceTypeInEnum(publicName, schemaRepository); + } + } + } + + private static IEnumerable GetBaseTypesToMapInto(ResourceSchemaType resourceSchemaType, bool forRequestSchema) + { + bool dependsOnCommonSchemaType = GetCommonSchemaType(resourceSchemaType.SchemaOpenType) != null; + + if (RepeatDiscriminatorInResponseDerivedTypes && !forRequestSchema) + { + ResourceType? baseType = resourceSchemaType.ResourceType.BaseType; + + while (baseType != null) + { + yield return baseType; + + baseType = baseType.BaseType; + } + } + else + { + if (!dependsOnCommonSchemaType) + { + yield return GetUltimateBaseType(resourceSchemaType.ResourceType); + } + } + + if (dependsOnCommonSchemaType) + { + yield return null; + } + } + + private void MapResourceTypeInEnum(string publicName, SchemaRepository schemaRepository) + { + string schemaId = _schemaIdSelector.GetResourceTypeSchemaId(null); + OpenApiSchema fullSchema = schemaRepository.Schemas[schemaId]; + + if (!fullSchema.Enum.Any(openApiAny => openApiAny is OpenApiString openApiString && openApiString.Value == publicName)) + { + fullSchema.Enum.Add(new OpenApiString(publicName)); + } + } + + private void SetDocumentation(OpenApiSchema fullSchema, ResourceType resourceType) + { + fullSchema.Description = _resourceDocumentationReader.GetDocumentationForType(resourceType); + } + + private void SetLinksVisibility(OpenApiSchema fullSchema, ResourceSchemaType resourceSchemaType, SchemaRepository schemaRepository) + { + _linksVisibilitySchemaGenerator.UpdateSchemaForResource(resourceSchemaType, fullSchema, schemaRepository); + } + + private void GenerateDataSchemasForDirectlyDerivedTypes(ResourceSchemaType resourceSchemaType, bool forRequestSchema, SchemaRepository schemaRepository) + { + OpenApiSchema referenceSchemaForBase = schemaRepository.LookupByType(resourceSchemaType.SchemaConstructedType); + + foreach (ResourceType derivedType in resourceSchemaType.ResourceType.DirectlyDerivedTypes) + { + ResourceSchemaType resourceSchemaTypeForDerived = resourceSchemaType.ChangeResourceType(derivedType); + Type derivedSchemaType = resourceSchemaTypeForDerived.SchemaConstructedType; + + using ISchemaGenerationTraceScope traceScope = _schemaGenerationTracer.TraceStart(this, resourceSchemaTypeForDerived.SchemaConstructedType); + + OpenApiSchema referenceSchemaForDerived = _defaultSchemaGenerator.GenerateSchema(derivedSchemaType, schemaRepository); + OpenApiSchema fullSchemaForDerived = schemaRepository.Schemas[referenceSchemaForDerived.Reference.Id]; + fullSchemaForDerived.AdditionalPropertiesAllowed = false; + + OpenApiSchema inlineSchemaForDerived = fullSchemaForDerived.UnwrapLastExtendedSchema(); + SetResourceFields(inlineSchemaForDerived, resourceSchemaTypeForDerived, forRequestSchema, schemaRepository); + + SetAbstract(inlineSchemaForDerived, resourceSchemaTypeForDerived); + RemoveProperties(inlineSchemaForDerived); + MapInDiscriminator(resourceSchemaTypeForDerived, forRequestSchema, JsonApiPropertyName.Type, schemaRepository); + + if (fullSchemaForDerived.AllOf.Count == 0) + { + var compositeSchemaForDerived = new OpenApiSchema + { + AllOf = + [ + referenceSchemaForBase, + fullSchemaForDerived + ], + AdditionalPropertiesAllowed = false + }; + + schemaRepository.Schemas[referenceSchemaForDerived.Reference.Id] = compositeSchemaForDerived; + } + else + { + fullSchemaForDerived.AllOf[0] = referenceSchemaForBase; + } + + if (RequiresRootObjectTypeInDataSchema(resourceSchemaTypeForDerived, forRequestSchema)) + { + OpenApiSchema fullSchemaForData = schemaRepository.Schemas[referenceSchemaForDerived.Reference.Id]; + fullSchemaForData.Extensions[SetSchemaTypeToObjectDocumentFilter.RequiresRootObjectTypeKey] = new OpenApiBoolean(true); + } + + GenerateDataSchemasForDirectlyDerivedTypes(resourceSchemaTypeForDerived, forRequestSchema, schemaRepository); + + traceScope.TraceSucceeded(referenceSchemaForDerived.Reference.Id); + } + } + + private static void RemoveProperties(OpenApiSchema fullSchema) + { + foreach (string propertyName in fullSchema.Properties.Keys) + { + fullSchema.Properties.Remove(propertyName); + fullSchema.Required.Remove(propertyName); + } + } + + private static bool RequiresRootObjectTypeInDataSchema(ResourceSchemaType resourceSchemaType, bool forRequestSchema) + { + Type? commonDataSchemaType = GetCommonSchemaType(resourceSchemaType.SchemaOpenType); + + if (forRequestSchema && (commonDataSchemaType == typeof(IdentifierInRequest) || + (!resourceSchemaType.ResourceType.ClrType.IsAbstract && commonDataSchemaType is { IsGenericType: false }))) + { + // Bug workaround for NSwag, which fails to properly infer implicit { "type": "object" } of outer schema when it appears inside an allOf. + // As a result, the required Data property in the generated client is assigned "default!" instead of a new instance. + // But there's another bug on top of that: When the declared type of Data is abstract, it still generates assignment with a new instance, which fails to compile. + return true; + } + + return false; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Components/LinksVisibilitySchemaGenerator.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Components/LinksVisibilitySchemaGenerator.cs new file mode 100644 index 0000000000..5c099a3fc1 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Components/LinksVisibilitySchemaGenerator.cs @@ -0,0 +1,208 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Documents; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Relationships; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.OpenApi.Swashbuckle.SwaggerComponents; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.SchemaGenerators.Components; + +/// +/// Hides links that are never returned. +/// +/// +/// Tradeoff: Special-casing links per resource type and per relationship means an explosion of expanded types, only because the links visibility may +/// vary. Furthermore, relationship links fallback to their left-type resource, whereas we generate right-type component schemas for relationships. To +/// keep it simple, we take the union of exposed links on resource types and relationships. Only what's not in this unification gets hidden. For example, +/// when options == None, typeof(Blogs) == Self, and typeof(Posts) == Related, we'll keep Self | Related for both Blogs and Posts, and remove any other +/// links. +/// +internal sealed class LinksVisibilitySchemaGenerator +{ + private const LinkTypes ResourceTopLinkTypes = LinkTypes.Self | LinkTypes.DescribedBy; + private const LinkTypes ResourceCollectionTopLinkTypes = LinkTypes.Self | LinkTypes.DescribedBy | LinkTypes.Pagination; + private const LinkTypes ResourceIdentifierTopLinkTypes = LinkTypes.Self | LinkTypes.Related | LinkTypes.DescribedBy; + private const LinkTypes ResourceIdentifierCollectionTopLinkTypes = LinkTypes.Self | LinkTypes.Related | LinkTypes.DescribedBy | LinkTypes.Pagination; + private const LinkTypes ErrorTopLinkTypes = LinkTypes.Self | LinkTypes.DescribedBy; + private const LinkTypes RelationshipLinkTypes = LinkTypes.Self | LinkTypes.Related; + private const LinkTypes ResourceLinkTypes = LinkTypes.Self; + + private static readonly Dictionary LinksInJsonApiSchemaTypes = new() + { + [typeof(NullableSecondaryResponseDocument<>)] = ResourceTopLinkTypes, + [typeof(PrimaryResponseDocument<>)] = ResourceTopLinkTypes, + [typeof(SecondaryResponseDocument<>)] = ResourceTopLinkTypes, + [typeof(CollectionResponseDocument<>)] = ResourceCollectionTopLinkTypes, + [typeof(IdentifierResponseDocument<>)] = ResourceIdentifierTopLinkTypes, + [typeof(NullableIdentifierResponseDocument<>)] = ResourceIdentifierTopLinkTypes, + [typeof(IdentifierCollectionResponseDocument<>)] = ResourceIdentifierCollectionTopLinkTypes, + [typeof(ErrorResponseDocument)] = ErrorTopLinkTypes, + [typeof(OperationsResponseDocument)] = ResourceTopLinkTypes, + [typeof(NullableToOneInResponse<>)] = RelationshipLinkTypes, + [typeof(ToManyInResponse<>)] = RelationshipLinkTypes, + [typeof(ToOneInResponse<>)] = RelationshipLinkTypes, + [typeof(DataInResponse<>)] = ResourceLinkTypes + }; + + private static readonly Dictionary> LinkTypeToPropertyNamesMap = new() + { + [LinkTypes.Self] = ["self"], + [LinkTypes.Related] = ["related"], + [LinkTypes.DescribedBy] = ["describedby"], + [LinkTypes.Pagination] = + [ + "first", + "last", + "prev", + "next" + ] + }; + + private readonly Lazy _lazyLinksVisibility; + + public LinksVisibilitySchemaGenerator(IJsonApiOptions options, IResourceGraph resourceGraph) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(resourceGraph); + + _lazyLinksVisibility = new Lazy(() => new LinksVisibility(options, resourceGraph), LazyThreadSafetyMode.ExecutionAndPublication); + } + + public void UpdateSchemaForTopLevel(Type schemaType, OpenApiSchema fullSchemaForLinksContainer, SchemaRepository schemaRepository) + { + ArgumentNullException.ThrowIfNull(schemaType); + ArgumentNullException.ThrowIfNull(fullSchemaForLinksContainer); + + Type lookupType = schemaType.ConstructedToOpenType(); + + if (LinksInJsonApiSchemaTypes.TryGetValue(lookupType, out LinkTypes possibleLinkTypes)) + { + UpdateLinksProperty(fullSchemaForLinksContainer, _lazyLinksVisibility.Value.TopLevelLinks, possibleLinkTypes, schemaRepository); + } + } + + public void UpdateSchemaForResource(ResourceSchemaType resourceSchemaType, OpenApiSchema fullSchemaForResourceData, SchemaRepository schemaRepository) + { + ArgumentNullException.ThrowIfNull(resourceSchemaType); + ArgumentNullException.ThrowIfNull(fullSchemaForResourceData); + + if (LinksInJsonApiSchemaTypes.TryGetValue(resourceSchemaType.SchemaOpenType, out LinkTypes possibleLinkTypes)) + { + UpdateLinksProperty(fullSchemaForResourceData, _lazyLinksVisibility.Value.ResourceLinks, possibleLinkTypes, schemaRepository); + } + } + + public void UpdateSchemaForRelationship(Type schemaType, OpenApiSchema fullSchemaForRelationship, SchemaRepository schemaRepository) + { + ArgumentNullException.ThrowIfNull(schemaType); + ArgumentNullException.ThrowIfNull(fullSchemaForRelationship); + + Type lookupType = schemaType.ConstructedToOpenType(); + + if (LinksInJsonApiSchemaTypes.TryGetValue(lookupType, out LinkTypes possibleLinkTypes)) + { + UpdateLinksProperty(fullSchemaForRelationship, _lazyLinksVisibility.Value.RelationshipLinks, possibleLinkTypes, schemaRepository); + } + } + + private void UpdateLinksProperty(OpenApiSchema fullSchemaForLinksContainer, LinkTypes visibleLinkTypes, LinkTypes possibleLinkTypes, + SchemaRepository schemaRepository) + { + OpenApiSchema referenceSchemaForLinks = fullSchemaForLinksContainer.Properties[JsonApiPropertyName.Links].UnwrapLastExtendedSchema(); + + if ((visibleLinkTypes & possibleLinkTypes) == 0) + { + fullSchemaForLinksContainer.Required.Remove(JsonApiPropertyName.Links); + fullSchemaForLinksContainer.Properties.Remove(JsonApiPropertyName.Links); + + schemaRepository.Schemas.Remove(referenceSchemaForLinks.Reference.Id); + } + else if (visibleLinkTypes != possibleLinkTypes) + { + string linksSchemaId = referenceSchemaForLinks.Reference.Id; + + if (schemaRepository.Schemas.TryGetValue(linksSchemaId, out OpenApiSchema? fullSchemaForLinks)) + { + UpdateLinkProperties(fullSchemaForLinks, visibleLinkTypes); + } + } + } + + private void UpdateLinkProperties(OpenApiSchema fullSchemaForLinks, LinkTypes availableLinkTypes) + { + foreach (string propertyName in LinkTypeToPropertyNamesMap.Where(pair => !availableLinkTypes.HasFlag(pair.Key)).SelectMany(pair => pair.Value)) + { + fullSchemaForLinks.Required.Remove(propertyName); + fullSchemaForLinks.Properties.Remove(propertyName); + } + } + + private sealed class LinksVisibility + { + public LinkTypes TopLevelLinks { get; } + public LinkTypes ResourceLinks { get; } + public LinkTypes RelationshipLinks { get; } + + public LinksVisibility(IJsonApiOptions options, IResourceGraph resourceGraph) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(resourceGraph); + + var unionTopLevelLinks = LinkTypes.None; + var unionResourceLinks = LinkTypes.None; + var unionRelationshipLinks = LinkTypes.None; + + foreach (ResourceType resourceType in resourceGraph.GetResourceTypes()) + { + LinkTypes topLevelLinks = GetTopLevelLinks(resourceType, options); + unionTopLevelLinks |= topLevelLinks; + + LinkTypes resourceLinks = GetResourceLinks(resourceType, options); + unionResourceLinks |= resourceLinks; + + LinkTypes relationshipLinks = GetRelationshipLinks(resourceType, options); + unionRelationshipLinks |= relationshipLinks; + } + + TopLevelLinks = Normalize(unionTopLevelLinks); + ResourceLinks = Normalize(unionResourceLinks); + RelationshipLinks = Normalize(unionRelationshipLinks); + } + + private LinkTypes GetTopLevelLinks(ResourceType resourceType, IJsonApiOptions options) + { + return resourceType.TopLevelLinks != LinkTypes.NotConfigured ? resourceType.TopLevelLinks : + options.TopLevelLinks == LinkTypes.NotConfigured ? LinkTypes.None : options.TopLevelLinks; + } + + private LinkTypes GetResourceLinks(ResourceType resourceType, IJsonApiOptions options) + { + return resourceType.ResourceLinks != LinkTypes.NotConfigured ? resourceType.ResourceLinks : + options.ResourceLinks == LinkTypes.NotConfigured ? LinkTypes.None : options.ResourceLinks; + } + + private LinkTypes GetRelationshipLinks(ResourceType resourceType, IJsonApiOptions options) + { + LinkTypes unionRelationshipLinks = resourceType.RelationshipLinks != LinkTypes.NotConfigured ? resourceType.RelationshipLinks : + options.RelationshipLinks == LinkTypes.NotConfigured ? LinkTypes.None : options.RelationshipLinks; + + foreach (RelationshipAttribute relationship in resourceType.Relationships) + { + LinkTypes relationshipLinks = relationship.Links != LinkTypes.NotConfigured ? relationship.Links : + relationship.LeftType.RelationshipLinks != LinkTypes.NotConfigured ? relationship.LeftType.RelationshipLinks : + options.RelationshipLinks == LinkTypes.NotConfigured ? LinkTypes.None : options.RelationshipLinks; + + unionRelationshipLinks |= relationshipLinks; + } + + return unionRelationshipLinks; + } + + private static LinkTypes Normalize(LinkTypes linkTypes) + { + return linkTypes != LinkTypes.None ? linkTypes & ~LinkTypes.None : linkTypes; + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Components/MetaSchemaGenerator.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Components/MetaSchemaGenerator.cs new file mode 100644 index 0000000000..1e1bd07852 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Components/MetaSchemaGenerator.cs @@ -0,0 +1,50 @@ +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.SchemaGenerators.Components; + +internal sealed class MetaSchemaGenerator +{ + private static readonly Type SchemaType = typeof(Meta); + private readonly SchemaGenerationTracer _schemaGenerationTracer; + private readonly JsonApiSchemaIdSelector _schemaIdSelector; + + public MetaSchemaGenerator(SchemaGenerationTracer schemaGenerationTracer, JsonApiSchemaIdSelector schemaIdSelector) + { + ArgumentNullException.ThrowIfNull(schemaGenerationTracer); + ArgumentNullException.ThrowIfNull(schemaIdSelector); + + _schemaGenerationTracer = schemaGenerationTracer; + _schemaIdSelector = schemaIdSelector; + } + + public OpenApiSchema GenerateSchema(SchemaRepository schemaRepository) + { + ArgumentNullException.ThrowIfNull(schemaRepository); + + if (schemaRepository.TryLookupByType(SchemaType, out OpenApiSchema? referenceSchema)) + { + return referenceSchema; + } + + using ISchemaGenerationTraceScope traceScope = _schemaGenerationTracer.TraceStart(this, SchemaType); + + var fullSchema = new OpenApiSchema + { + Type = "object", + AdditionalProperties = new OpenApiSchema + { + Nullable = true + } + }; + + string schemaId = _schemaIdSelector.GetMetaSchemaId(); + + referenceSchema = schemaRepository.AddDefinition(schemaId, fullSchema); + schemaRepository.RegisterType(SchemaType, schemaId); + + traceScope.TraceSucceeded(schemaId); + return referenceSchema; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Components/RelationshipIdentifierSchemaGenerator.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Components/RelationshipIdentifierSchemaGenerator.cs new file mode 100644 index 0000000000..98f176e8df --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Components/RelationshipIdentifierSchemaGenerator.cs @@ -0,0 +1,94 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.SchemaGenerators.Components; + +internal sealed class RelationshipIdentifierSchemaGenerator +{ + private readonly SchemaGenerationTracer _schemaGenerationTracer; + private readonly SchemaGenerator _defaultSchemaGenerator; + private readonly ResourceTypeSchemaGenerator _resourceTypeSchemaGenerator; + private readonly ResourceIdSchemaGenerator _resourceIdSchemaGenerator; + private readonly RelationshipNameSchemaGenerator _relationshipNameSchemaGenerator; + private readonly JsonApiSchemaIdSelector _schemaIdSelector; + + public RelationshipIdentifierSchemaGenerator(SchemaGenerationTracer schemaGenerationTracer, SchemaGenerator defaultSchemaGenerator, + ResourceTypeSchemaGenerator resourceTypeSchemaGenerator, ResourceIdSchemaGenerator resourceIdSchemaGenerator, + RelationshipNameSchemaGenerator relationshipNameSchemaGenerator, JsonApiSchemaIdSelector schemaIdSelector) + { + ArgumentNullException.ThrowIfNull(schemaGenerationTracer); + ArgumentNullException.ThrowIfNull(defaultSchemaGenerator); + ArgumentNullException.ThrowIfNull(resourceTypeSchemaGenerator); + ArgumentNullException.ThrowIfNull(resourceIdSchemaGenerator); + ArgumentNullException.ThrowIfNull(relationshipNameSchemaGenerator); + ArgumentNullException.ThrowIfNull(schemaIdSelector); + + _schemaGenerationTracer = schemaGenerationTracer; + _defaultSchemaGenerator = defaultSchemaGenerator; + _resourceTypeSchemaGenerator = resourceTypeSchemaGenerator; + _resourceIdSchemaGenerator = resourceIdSchemaGenerator; + _relationshipNameSchemaGenerator = relationshipNameSchemaGenerator; + _schemaIdSelector = schemaIdSelector; + } + + public OpenApiSchema GenerateSchema(RelationshipAttribute relationship, SchemaRepository schemaRepository) + { + ArgumentNullException.ThrowIfNull(relationship); + ArgumentNullException.ThrowIfNull(schemaRepository); + + string schemaId = _schemaIdSelector.GetRelationshipIdentifierSchemaId(relationship); + + if (schemaRepository.Schemas.ContainsKey(schemaId)) + { + return new OpenApiSchema + { + Reference = new OpenApiReference + { + Id = schemaId, + Type = ReferenceType.Schema + } + }; + } + + using ISchemaGenerationTraceScope traceScope = _schemaGenerationTracer.TraceStart(this, relationship); + + Type relationshipIdentifierConstructedType = typeof(RelationshipIdentifier<>).MakeGenericType(relationship.LeftType.ClrType); + ConsistencyGuard.ThrowIf(schemaRepository.TryLookupByType(relationshipIdentifierConstructedType, out _)); + + OpenApiSchema referenceSchemaForIdentifier = _defaultSchemaGenerator.GenerateSchema(relationshipIdentifierConstructedType, schemaRepository); + OpenApiSchema fullSchemaForIdentifier = schemaRepository.Schemas[referenceSchemaForIdentifier.Reference.Id]; + + fullSchemaForIdentifier.Properties.Remove(JsonApiPropertyName.Meta); + + SetResourceType(fullSchemaForIdentifier, relationship.LeftType, schemaRepository); + SetResourceId(fullSchemaForIdentifier, relationship.LeftType, schemaRepository); + SetRelationship(fullSchemaForIdentifier, relationship, schemaRepository); + + schemaRepository.ReplaceSchemaId(relationshipIdentifierConstructedType, schemaId); + referenceSchemaForIdentifier.Reference.Id = schemaId; + + traceScope.TraceSucceeded(schemaId); + return referenceSchemaForIdentifier; + } + + private void SetResourceType(OpenApiSchema fullSchemaForIdentifier, ResourceType resourceType, SchemaRepository schemaRepository) + { + OpenApiSchema referenceSchema = _resourceTypeSchemaGenerator.GenerateSchema(resourceType, schemaRepository); + fullSchemaForIdentifier.Properties[JsonApiPropertyName.Type] = referenceSchema.WrapInExtendedSchema(); + } + + private void SetResourceId(OpenApiSchema fullSchemaForResourceData, ResourceType resourceType, SchemaRepository schemaRepository) + { + OpenApiSchema idSchema = _resourceIdSchemaGenerator.GenerateSchema(resourceType, schemaRepository); + fullSchemaForResourceData.Properties[JsonApiPropertyName.Id] = idSchema; + } + + private void SetRelationship(OpenApiSchema fullSchemaForIdentifier, RelationshipAttribute relationship, SchemaRepository schemaRepository) + { + OpenApiSchema referenceSchema = _relationshipNameSchemaGenerator.GenerateSchema(relationship, schemaRepository); + fullSchemaForIdentifier.Properties[JsonApiPropertyName.Relationship] = referenceSchema.WrapInExtendedSchema(); + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Components/RelationshipNameSchemaGenerator.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Components/RelationshipNameSchemaGenerator.cs new file mode 100644 index 0000000000..7f5c0c5d3a --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Components/RelationshipNameSchemaGenerator.cs @@ -0,0 +1,54 @@ +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.SchemaGenerators.Components; + +internal sealed class RelationshipNameSchemaGenerator +{ + private readonly SchemaGenerationTracer _schemaGenerationTracer; + private readonly JsonApiSchemaIdSelector _schemaIdSelector; + + public RelationshipNameSchemaGenerator(SchemaGenerationTracer schemaGenerationTracer, JsonApiSchemaIdSelector schemaIdSelector) + { + ArgumentNullException.ThrowIfNull(schemaGenerationTracer); + ArgumentNullException.ThrowIfNull(schemaIdSelector); + + _schemaGenerationTracer = schemaGenerationTracer; + _schemaIdSelector = schemaIdSelector; + } + + public OpenApiSchema GenerateSchema(RelationshipAttribute relationship, SchemaRepository schemaRepository) + { + ArgumentNullException.ThrowIfNull(relationship); + ArgumentNullException.ThrowIfNull(schemaRepository); + + string schemaId = _schemaIdSelector.GetRelationshipNameSchemaId(relationship); + + if (schemaRepository.Schemas.ContainsKey(schemaId)) + { + return new OpenApiSchema + { + Reference = new OpenApiReference + { + Id = schemaId, + Type = ReferenceType.Schema + } + }; + } + + using ISchemaGenerationTraceScope traceScope = _schemaGenerationTracer.TraceStart(this, relationship); + + var fullSchema = new OpenApiSchema + { + Type = "string", + Enum = [new OpenApiString(relationship.PublicName)] + }; + + OpenApiSchema referenceSchema = schemaRepository.AddDefinition(schemaId, fullSchema); + + traceScope.TraceSucceeded(schemaId); + return referenceSchema; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Components/ResourceIdSchemaGenerator.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Components/ResourceIdSchemaGenerator.cs new file mode 100644 index 0000000000..21bc5020ed --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Components/ResourceIdSchemaGenerator.cs @@ -0,0 +1,45 @@ +using JsonApiDotNetCore.Configuration; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.SchemaGenerators.Components; + +internal sealed class ResourceIdSchemaGenerator +{ + private readonly SchemaGenerator _defaultSchemaGenerator; + + public ResourceIdSchemaGenerator(SchemaGenerator defaultSchemaGenerator) + { + ArgumentNullException.ThrowIfNull(defaultSchemaGenerator); + + _defaultSchemaGenerator = defaultSchemaGenerator; + } + + public OpenApiSchema GenerateSchema(ResourceType resourceType, SchemaRepository schemaRepository) + { + ArgumentNullException.ThrowIfNull(resourceType); + + return GenerateSchema(resourceType.IdentityClrType, schemaRepository); + } + + public OpenApiSchema GenerateSchema(Type resourceIdClrType, SchemaRepository schemaRepository) + { + ArgumentNullException.ThrowIfNull(resourceIdClrType); + ArgumentNullException.ThrowIfNull(schemaRepository); + + OpenApiSchema idSchema = _defaultSchemaGenerator.GenerateSchema(resourceIdClrType, schemaRepository); + ConsistencyGuard.ThrowIf(idSchema.Reference != null); + + idSchema.Type = "string"; + + if (resourceIdClrType != typeof(string)) + { + // When using string IDs, it's discouraged (but possible) to use an empty string as primary key value, because + // some things won't work: get-by-id, update and delete resource are impossible, and rendered links are unusable. + // For other ID types, provide the length constraint as a fallback in case the type hint isn't recognized. + idSchema.MinLength = 1; + } + + return idSchema; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Components/ResourceTypeSchemaGenerator.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Components/ResourceTypeSchemaGenerator.cs new file mode 100644 index 0000000000..eb933bedbe --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Components/ResourceTypeSchemaGenerator.cs @@ -0,0 +1,91 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.OpenApi.Swashbuckle.SwaggerComponents; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.SchemaGenerators.Components; + +internal sealed class ResourceTypeSchemaGenerator +{ + private readonly SchemaGenerationTracer _schemaGenerationTracer; + private readonly JsonApiSchemaIdSelector _schemaIdSelector; + + public ResourceTypeSchemaGenerator(SchemaGenerationTracer schemaGenerationTracer, JsonApiSchemaIdSelector schemaIdSelector) + { + ArgumentNullException.ThrowIfNull(schemaGenerationTracer); + ArgumentNullException.ThrowIfNull(schemaIdSelector); + + _schemaGenerationTracer = schemaGenerationTracer; + _schemaIdSelector = schemaIdSelector; + } + + public OpenApiSchema GenerateSchema(ResourceType resourceType, SchemaRepository schemaRepository) + { + ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(schemaRepository); + + if (schemaRepository.TryLookupByType(resourceType.ClrType, out OpenApiSchema? referenceSchema)) + { + return referenceSchema; + } + + using ISchemaGenerationTraceScope traceScope = _schemaGenerationTracer.TraceStart(this, resourceType.ClrType); + + var fullSchema = new OpenApiSchema + { + Type = "string", + Enum = resourceType.ClrType.IsAbstract ? [] : [new OpenApiString(resourceType.PublicName)], + Extensions = + { + [StringEnumOrderingFilter.RequiresSortKey] = new OpenApiBoolean(true) + } + }; + + foreach (ResourceType derivedType in resourceType.GetAllConcreteDerivedTypes()) + { + fullSchema.Enum.Add(new OpenApiString(derivedType.PublicName)); + } + + string schemaId = _schemaIdSelector.GetResourceTypeSchemaId(resourceType); + + referenceSchema = schemaRepository.AddDefinition(schemaId, fullSchema); + schemaRepository.RegisterType(resourceType.ClrType, schemaId); + + traceScope.TraceSucceeded(schemaId); + return referenceSchema; + } + + public OpenApiSchema GenerateSchema(SchemaRepository schemaRepository) + { + string schemaId = _schemaIdSelector.GetResourceTypeSchemaId(null); + + if (schemaRepository.Schemas.ContainsKey(schemaId)) + { + return new OpenApiSchema + { + Reference = new OpenApiReference + { + Id = schemaId, + Type = ReferenceType.Schema + } + }; + } + + using ISchemaGenerationTraceScope traceScope = _schemaGenerationTracer.TraceStart(this); + + var fullSchema = new OpenApiSchema + { + Type = "string", + Extensions = + { + [StringEnumOrderingFilter.RequiresSortKey] = new OpenApiBoolean(true) + } + }; + + OpenApiSchema referenceSchema = schemaRepository.AddDefinition(schemaId, fullSchema); + + traceScope.TraceSucceeded(schemaId); + return referenceSchema; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Documents/AtomicOperationsDocumentSchemaGenerator.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Documents/AtomicOperationsDocumentSchemaGenerator.cs new file mode 100644 index 0000000000..f128f89299 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Documents/AtomicOperationsDocumentSchemaGenerator.cs @@ -0,0 +1,485 @@ +using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.AtomicOperations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Documents; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.OpenApi.Swashbuckle.SchemaGenerators.Components; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.SchemaGenerators.Documents; + +/// +/// Generates the OpenAPI component schema for an atomic:operations request/response document. +/// +internal sealed class AtomicOperationsDocumentSchemaGenerator : DocumentSchemaGenerator +{ + private static readonly Type AtomicOperationAbstractType = typeof(AtomicOperation); + + private readonly SchemaGenerationTracer _schemaGenerationTracer; + private readonly SchemaGenerator _defaultSchemaGenerator; + private readonly AtomicOperationCodeSchemaGenerator _atomicOperationCodeSchemaGenerator; + private readonly DataSchemaGenerator _dataSchemaGenerator; + private readonly RelationshipIdentifierSchemaGenerator _relationshipIdentifierSchemaGenerator; + private readonly DataContainerSchemaGenerator _dataContainerSchemaGenerator; + private readonly MetaSchemaGenerator _metaSchemaGenerator; + private readonly IAtomicOperationFilter _atomicOperationFilter; + private readonly JsonApiSchemaIdSelector _schemaIdSelector; + private readonly ResourceFieldValidationMetadataProvider _resourceFieldValidationMetadataProvider; + private readonly IResourceGraph _resourceGraph; + + public AtomicOperationsDocumentSchemaGenerator(SchemaGenerationTracer schemaGenerationTracer, SchemaGenerator defaultSchemaGenerator, + AtomicOperationCodeSchemaGenerator atomicOperationCodeSchemaGenerator, DataSchemaGenerator dataSchemaGenerator, + RelationshipIdentifierSchemaGenerator relationshipIdentifierSchemaGenerator, DataContainerSchemaGenerator dataContainerSchemaGenerator, + MetaSchemaGenerator metaSchemaGenerator, LinksVisibilitySchemaGenerator linksVisibilitySchemaGenerator, IAtomicOperationFilter atomicOperationFilter, + JsonApiSchemaIdSelector schemaIdSelector, ResourceFieldValidationMetadataProvider resourceFieldValidationMetadataProvider, IJsonApiOptions options, + IResourceGraph resourceGraph) + : base(schemaGenerationTracer, metaSchemaGenerator, linksVisibilitySchemaGenerator, options) + { + ArgumentNullException.ThrowIfNull(defaultSchemaGenerator); + ArgumentNullException.ThrowIfNull(atomicOperationCodeSchemaGenerator); + ArgumentNullException.ThrowIfNull(dataSchemaGenerator); + ArgumentNullException.ThrowIfNull(relationshipIdentifierSchemaGenerator); + ArgumentNullException.ThrowIfNull(dataContainerSchemaGenerator); + ArgumentNullException.ThrowIfNull(atomicOperationFilter); + ArgumentNullException.ThrowIfNull(schemaIdSelector); + ArgumentNullException.ThrowIfNull(resourceFieldValidationMetadataProvider); + ArgumentNullException.ThrowIfNull(resourceGraph); + + _schemaGenerationTracer = schemaGenerationTracer; + _defaultSchemaGenerator = defaultSchemaGenerator; + _atomicOperationCodeSchemaGenerator = atomicOperationCodeSchemaGenerator; + _dataSchemaGenerator = dataSchemaGenerator; + _relationshipIdentifierSchemaGenerator = relationshipIdentifierSchemaGenerator; + _dataContainerSchemaGenerator = dataContainerSchemaGenerator; + _metaSchemaGenerator = metaSchemaGenerator; + _atomicOperationFilter = atomicOperationFilter; + _schemaIdSelector = schemaIdSelector; + _resourceFieldValidationMetadataProvider = resourceFieldValidationMetadataProvider; + _resourceGraph = resourceGraph; + } + + public override bool CanGenerate(Type schemaType) + { + return schemaType == typeof(OperationsRequestDocument) || schemaType == typeof(OperationsResponseDocument); + } + + protected override OpenApiSchema GenerateDocumentSchema(Type schemaType, SchemaRepository schemaRepository) + { + ArgumentNullException.ThrowIfNull(schemaType); + ArgumentNullException.ThrowIfNull(schemaRepository); + + bool isRequestSchema = schemaType == typeof(OperationsRequestDocument); + + if (isRequestSchema) + { + GenerateSchemasForRequestDocument(schemaRepository); + } + else + { + GenerateSchemasForResponseDocument(schemaRepository); + } + + return _defaultSchemaGenerator.GenerateSchema(schemaType, schemaRepository); + } + + private void GenerateSchemasForRequestDocument(SchemaRepository schemaRepository) + { + _ = GenerateSchemaForAbstractOperation(schemaRepository); + + foreach (ResourceType resourceType in _resourceGraph.GetResourceTypes().Where(resourceType => resourceType.BaseType == null)) + { + GenerateSchemaForOperation(resourceType, schemaRepository); + } + } + + private OpenApiSchema GenerateSchemaForAbstractOperation(SchemaRepository schemaRepository) + { + if (schemaRepository.TryLookupByType(AtomicOperationAbstractType, out OpenApiSchema? referenceSchema)) + { + return referenceSchema; + } + + using ISchemaGenerationTraceScope traceScope = _schemaGenerationTracer.TraceStart(this, AtomicOperationAbstractType); + + OpenApiSchema referenceSchemaForMeta = _metaSchemaGenerator.GenerateSchema(schemaRepository); + + var fullSchema = new OpenApiSchema + { + Type = "object", + Required = new SortedSet([OpenApiMediaTypeExtension.FullyQualifiedOpenApiDiscriminatorPropertyName]), + Properties = new Dictionary + { + [OpenApiMediaTypeExtension.FullyQualifiedOpenApiDiscriminatorPropertyName] = new() + { + Type = "string" + }, + [referenceSchemaForMeta.Reference.Id] = referenceSchemaForMeta.WrapInExtendedSchema() + }, + AdditionalPropertiesAllowed = false, + Discriminator = new OpenApiDiscriminator + { + PropertyName = OpenApiMediaTypeExtension.FullyQualifiedOpenApiDiscriminatorPropertyName, + Mapping = new SortedDictionary(StringComparer.Ordinal) + }, + Extensions = + { + ["x-abstract"] = new OpenApiBoolean(true) + } + }; + + string schemaId = _schemaIdSelector.GetSchemaId(AtomicOperationAbstractType); + + referenceSchema = schemaRepository.AddDefinition(schemaId, fullSchema); + schemaRepository.RegisterType(AtomicOperationAbstractType, schemaId); + + traceScope.TraceSucceeded(schemaId); + return referenceSchema; + } + + private void GenerateSchemaForOperation(ResourceType resourceType, SchemaRepository schemaRepository) + { + GenerateSchemaForResourceOperation(typeof(CreateOperation<>), resourceType, AtomicOperationCode.Add, schemaRepository); + GenerateSchemaForResourceOperation(typeof(UpdateOperation<>), resourceType, AtomicOperationCode.Update, schemaRepository); + GenerateSchemaForResourceOperation(typeof(DeleteOperation<>), resourceType, AtomicOperationCode.Remove, schemaRepository); + + foreach (RelationshipAttribute relationship in GetRelationshipsInTypeHierarchy(resourceType)) + { + if (relationship is HasOneAttribute) + { + GenerateSchemaForRelationshipOperation(typeof(UpdateToOneRelationshipOperation<>), relationship, AtomicOperationCode.Update, schemaRepository); + } + else + { + GenerateSchemaForRelationshipOperation(typeof(AddToRelationshipOperation<>), relationship, AtomicOperationCode.Add, schemaRepository); + GenerateSchemaForRelationshipOperation(typeof(UpdateToManyRelationshipOperation<>), relationship, AtomicOperationCode.Update, schemaRepository); + GenerateSchemaForRelationshipOperation(typeof(RemoveFromRelationshipOperation<>), relationship, AtomicOperationCode.Remove, schemaRepository); + } + } + } + + private void GenerateSchemaForResourceOperation(Type operationOpenType, ResourceType resourceType, AtomicOperationCode operationCode, + SchemaRepository schemaRepository) + { + WriteOperationKind writeOperation = GetKindOfResourceOperation(operationCode); + + if (IsResourceTypeEnabled(resourceType, writeOperation)) + { + Type operationConstructedType = ChangeResourceTypeInSchemaType(operationOpenType, resourceType); + + using ISchemaGenerationTraceScope traceScope = _schemaGenerationTracer.TraceStart(this, operationConstructedType); + + bool needsEmptyDerivedSchema = resourceType.BaseType != null && _atomicOperationFilter.IsEnabled(resourceType.BaseType, writeOperation); + + if (!needsEmptyDerivedSchema) + { + Type identifierSchemaType = typeof(IdentifierInRequest<>).MakeGenericType(resourceType.ClrType); + _ = _dataSchemaGenerator.GenerateSchema(identifierSchemaType, true, schemaRepository); + + bool hasDataProperty = operationOpenType != typeof(DeleteOperation<>); + + if (hasDataProperty) + { + _ = _dataContainerSchemaGenerator.GenerateSchema(operationConstructedType, resourceType, true, false, schemaRepository); + } + } + + OpenApiSchema referenceSchemaForOperation = _defaultSchemaGenerator.GenerateSchema(operationConstructedType, schemaRepository); + OpenApiSchema fullSchemaForOperation = schemaRepository.Schemas[referenceSchemaForOperation.Reference.Id]; + fullSchemaForOperation.AdditionalPropertiesAllowed = false; + OpenApiSchema inlineSchemaForOperation = fullSchemaForOperation.UnwrapLastExtendedSchema(); + + if (needsEmptyDerivedSchema) + { + Type baseOperationSchemaType = ChangeResourceTypeInSchemaType(operationOpenType, resourceType.BaseType!); + OpenApiSchema referenceSchemaForBaseOperation = schemaRepository.LookupByType(baseOperationSchemaType); + + RemoveProperties(inlineSchemaForOperation); + fullSchemaForOperation.AllOf[0] = referenceSchemaForBaseOperation; + } + else + { + SetOperationCode(inlineSchemaForOperation, operationCode, schemaRepository); + } + + string discriminatorValue = _schemaIdSelector.GetAtomicOperationDiscriminatorValue(operationCode, resourceType); + MapInDiscriminator(referenceSchemaForOperation, discriminatorValue, schemaRepository); + + traceScope.TraceSucceeded(referenceSchemaForOperation.Reference.Id); + } + + foreach (ResourceType derivedType in resourceType.DirectlyDerivedTypes) + { + GenerateSchemaForResourceOperation(operationOpenType, derivedType, operationCode, schemaRepository); + } + } + + private static WriteOperationKind GetKindOfResourceOperation(AtomicOperationCode operationCode) + { + WriteOperationKind? writeOperation = null; + + if (operationCode == AtomicOperationCode.Add) + { + writeOperation = WriteOperationKind.CreateResource; + } + else if (operationCode == AtomicOperationCode.Update) + { + writeOperation = WriteOperationKind.UpdateResource; + } + else if (operationCode == AtomicOperationCode.Remove) + { + writeOperation = WriteOperationKind.DeleteResource; + } + + ConsistencyGuard.ThrowIf(writeOperation == null); + return writeOperation.Value; + } + + private bool IsResourceTypeEnabled(ResourceType resourceType, WriteOperationKind writeOperation) + { + return _atomicOperationFilter.IsEnabled(resourceType, writeOperation); + } + + private static Type ChangeResourceTypeInSchemaType(Type schemaOpenType, ResourceType resourceType) + { + return schemaOpenType.MakeGenericType(resourceType.ClrType); + } + + private static void RemoveProperties(OpenApiSchema fullSchema) + { + foreach (string propertyName in fullSchema.Properties.Keys) + { + fullSchema.Properties.Remove(propertyName); + fullSchema.Required.Remove(propertyName); + } + } + + private void SetOperationCode(OpenApiSchema fullSchema, AtomicOperationCode operationCode, SchemaRepository schemaRepository) + { + OpenApiSchema referenceSchema = _atomicOperationCodeSchemaGenerator.GenerateSchema(operationCode, schemaRepository); + fullSchema.Properties[JsonApiPropertyName.Op] = referenceSchema.WrapInExtendedSchema(); + } + + private static void MapInDiscriminator(OpenApiSchema referenceSchemaForOperation, string discriminatorValue, SchemaRepository schemaRepository) + { + OpenApiSchema referenceSchemaForAbstractOperation = schemaRepository.LookupByType(AtomicOperationAbstractType); + OpenApiSchema fullSchemaForAbstractOperation = schemaRepository.Schemas[referenceSchemaForAbstractOperation.Reference.Id]; + fullSchemaForAbstractOperation.Discriminator.Mapping.Add(discriminatorValue, referenceSchemaForOperation.Reference.ReferenceV3); + } + + private static HashSet GetRelationshipsInTypeHierarchy(ResourceType baseType) + { + HashSet relationships = baseType.Relationships.ToHashSet(); + + if (baseType.IsPartOfTypeHierarchy()) + { + IncludeRelationshipsInDirectlyDerivedTypes(baseType, relationships); + } + + return relationships; + } + + private static void IncludeRelationshipsInDirectlyDerivedTypes(ResourceType baseType, HashSet relationships) + { + foreach (ResourceType derivedType in baseType.DirectlyDerivedTypes) + { + IncludeRelationshipsInDerivedType(derivedType, relationships); + } + } + + private static void IncludeRelationshipsInDerivedType(ResourceType derivedType, HashSet relationships) + { + foreach (RelationshipAttribute relationshipInDerivedType in derivedType.Relationships) + { + relationships.Add(relationshipInDerivedType); + } + + IncludeRelationshipsInDirectlyDerivedTypes(derivedType, relationships); + } + + private void GenerateSchemaForRelationshipOperation(Type operationOpenType, RelationshipAttribute relationship, AtomicOperationCode operationCode, + SchemaRepository schemaRepository) + { + WriteOperationKind writeOperation = GetKindOfRelationshipOperation(operationCode); + + if (!IsRelationshipEnabled(relationship, writeOperation)) + { + return; + } + + using ISchemaGenerationTraceScope traceScope = _schemaGenerationTracer.TraceStart(this, operationOpenType, relationship); + + RelationshipAttribute? relationshipInAnyBaseResourceType = GetRelationshipEnabledInAnyBase(relationship, writeOperation); + + OpenApiSchema? referenceSchemaForRelationshipIdentifier; + + if (relationshipInAnyBaseResourceType == null) + { + Type rightSchemaType = typeof(IdentifierInRequest<>).MakeGenericType(relationship.RightType.ClrType); + _ = _dataSchemaGenerator.GenerateSchema(rightSchemaType, true, schemaRepository); + + referenceSchemaForRelationshipIdentifier = _relationshipIdentifierSchemaGenerator.GenerateSchema(relationship, schemaRepository); + } + else + { + referenceSchemaForRelationshipIdentifier = null; + } + + Type operationConstructedType = ChangeResourceTypeInSchemaType(operationOpenType, relationship.RightType); + _ = _dataContainerSchemaGenerator.GenerateSchema(operationConstructedType, relationship.RightType, true, false, schemaRepository); + + // This complicated implementation that generates a temporary schema stems from the fact that GetSchemaId takes a Type. + // We could feed it a constructed type with TLeftResource and TRightResource, but there's no way to include + // the relationship name because there's no runtime Type available for it. + string schemaId = _schemaIdSelector.GetRelationshipAtomicOperationSchemaId(relationship, operationCode); + + OpenApiSchema referenceSchemaForOperation = _defaultSchemaGenerator.GenerateSchema(operationConstructedType, schemaRepository); + OpenApiSchema fullSchemaForOperation = schemaRepository.Schemas[referenceSchemaForOperation.Reference.Id]; + fullSchemaForOperation.AdditionalPropertiesAllowed = false; + + OpenApiSchema inlineSchemaForOperation = fullSchemaForOperation.UnwrapLastExtendedSchema(); + SetOperationCode(inlineSchemaForOperation, operationCode, schemaRepository); + + if (referenceSchemaForRelationshipIdentifier != null) + { + inlineSchemaForOperation.Properties[JsonApiPropertyName.Ref] = referenceSchemaForRelationshipIdentifier.WrapInExtendedSchema(); + } + + inlineSchemaForOperation.Properties[JsonApiPropertyName.Data].Nullable = _resourceFieldValidationMetadataProvider.IsNullable(relationship); + + schemaRepository.ReplaceSchemaId(operationConstructedType, schemaId); + referenceSchemaForOperation.Reference.Id = schemaId; + + if (relationshipInAnyBaseResourceType != null) + { + RemoveProperties(inlineSchemaForOperation); + + string baseRelationshipSchemaId = _schemaIdSelector.GetRelationshipAtomicOperationSchemaId(relationshipInAnyBaseResourceType, operationCode); + ConsistencyGuard.ThrowIf(!schemaRepository.Schemas.ContainsKey(baseRelationshipSchemaId)); + + fullSchemaForOperation.AllOf[0] = new OpenApiSchema + { + Reference = new OpenApiReference + { + Id = baseRelationshipSchemaId, + Type = ReferenceType.Schema + } + }; + } + + string discriminatorValue = _schemaIdSelector.GetAtomicOperationDiscriminatorValue(operationCode, relationship); + MapInDiscriminator(referenceSchemaForOperation, discriminatorValue, schemaRepository); + + traceScope.TraceSucceeded(schemaId); + } + + private static WriteOperationKind GetKindOfRelationshipOperation(AtomicOperationCode operationCode) + { + WriteOperationKind? writeOperation = null; + + if (operationCode == AtomicOperationCode.Add) + { + writeOperation = WriteOperationKind.AddToRelationship; + } + else if (operationCode == AtomicOperationCode.Update) + { + writeOperation = WriteOperationKind.SetRelationship; + } + else if (operationCode == AtomicOperationCode.Remove) + { + writeOperation = WriteOperationKind.RemoveFromRelationship; + } + + ConsistencyGuard.ThrowIf(writeOperation == null); + return writeOperation.Value; + } + + private bool IsRelationshipEnabled(RelationshipAttribute relationship, WriteOperationKind writeOperation) + { + if (!_atomicOperationFilter.IsEnabled(relationship.LeftType, writeOperation)) + { + return false; + } + + if (relationship is HasOneAttribute hasOneRelationship && !IsToOneRelationshipEnabled(hasOneRelationship, writeOperation)) + { + return false; + } + + if (relationship is HasManyAttribute hasManyRelationship && !IsToManyRelationshipEnabled(hasManyRelationship, writeOperation)) + { + return false; + } + + return true; + } + + private static bool IsToOneRelationshipEnabled(HasOneAttribute relationship, WriteOperationKind writeOperation) + { + bool? isEnabled = null; + + if (writeOperation == WriteOperationKind.SetRelationship) + { + isEnabled = relationship.Capabilities.HasFlag(HasOneCapabilities.AllowSet); + } + + ConsistencyGuard.ThrowIf(isEnabled == null); + return isEnabled.Value; + } + + private static bool IsToManyRelationshipEnabled(HasManyAttribute relationship, WriteOperationKind writeOperation) + { + bool? isEnabled = null; + + if (writeOperation == WriteOperationKind.SetRelationship) + { + isEnabled = relationship.Capabilities.HasFlag(HasManyCapabilities.AllowSet); + } + else if (writeOperation == WriteOperationKind.AddToRelationship) + { + isEnabled = relationship.Capabilities.HasFlag(HasManyCapabilities.AllowAdd); + } + else if (writeOperation == WriteOperationKind.RemoveFromRelationship) + { + isEnabled = relationship.Capabilities.HasFlag(HasManyCapabilities.AllowRemove); + } + + ConsistencyGuard.ThrowIf(isEnabled == null); + return isEnabled.Value; + } + + private RelationshipAttribute? GetRelationshipEnabledInAnyBase(RelationshipAttribute relationship, WriteOperationKind writeOperation) + { + RelationshipAttribute? relationshipInBaseResourceType = relationship.LeftType.BaseType?.FindRelationshipByPublicName(relationship.PublicName); + + while (relationshipInBaseResourceType != null) + { + if (IsRelationshipEnabled(relationshipInBaseResourceType, writeOperation)) + { + return relationshipInBaseResourceType; + } + + relationshipInBaseResourceType = relationshipInBaseResourceType.LeftType.BaseType?.FindRelationshipByPublicName(relationship.PublicName); + } + + return null; + } + + private void GenerateSchemasForResponseDocument(SchemaRepository schemaRepository) + { + _ = _dataContainerSchemaGenerator.GenerateSchemaForCommonResourceDataInResponse(schemaRepository); + + foreach (ResourceType resourceType in _resourceGraph.GetResourceTypes()) + { + if (IsResourceTypeEnabled(resourceType, WriteOperationKind.CreateResource) || + IsResourceTypeEnabled(resourceType, WriteOperationKind.UpdateResource)) + { + _ = _dataContainerSchemaGenerator.GenerateSchema(typeof(AtomicResult), resourceType, false, false, schemaRepository); + } + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Documents/DocumentSchemaGenerator.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Documents/DocumentSchemaGenerator.cs new file mode 100644 index 0000000000..740b2fca43 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Documents/DocumentSchemaGenerator.cs @@ -0,0 +1,69 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.OpenApi.Swashbuckle.SchemaGenerators.Components; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.SchemaGenerators.Documents; + +/// +/// Generates the OpenAPI component schema for a request and/or response document. +/// +internal abstract class DocumentSchemaGenerator +{ + private readonly SchemaGenerationTracer _schemaGenerationTracer; + private readonly MetaSchemaGenerator _metaSchemaGenerator; + private readonly LinksVisibilitySchemaGenerator _linksVisibilitySchemaGenerator; + private readonly IJsonApiOptions _options; + + protected DocumentSchemaGenerator(SchemaGenerationTracer schemaGenerationTracer, MetaSchemaGenerator metaSchemaGenerator, + LinksVisibilitySchemaGenerator linksVisibilitySchemaGenerator, IJsonApiOptions options) + { + ArgumentNullException.ThrowIfNull(schemaGenerationTracer); + ArgumentNullException.ThrowIfNull(metaSchemaGenerator); + ArgumentNullException.ThrowIfNull(linksVisibilitySchemaGenerator); + ArgumentNullException.ThrowIfNull(options); + + _schemaGenerationTracer = schemaGenerationTracer; + _metaSchemaGenerator = metaSchemaGenerator; + _linksVisibilitySchemaGenerator = linksVisibilitySchemaGenerator; + _options = options; + } + + public abstract bool CanGenerate(Type schemaType); + + public OpenApiSchema GenerateSchema(Type schemaType, SchemaRepository schemaRepository) + { + ArgumentNullException.ThrowIfNull(schemaType); + ArgumentNullException.ThrowIfNull(schemaRepository); + + if (schemaRepository.TryLookupByType(schemaType, out OpenApiSchema? referenceSchema)) + { + return referenceSchema; + } + + using ISchemaGenerationTraceScope traceScope = _schemaGenerationTracer.TraceStart(this, schemaType); + + _metaSchemaGenerator.GenerateSchema(schemaRepository); + + referenceSchema = GenerateDocumentSchema(schemaType, schemaRepository); + OpenApiSchema fullSchema = schemaRepository.Schemas[referenceSchema.Reference.Id]; + + _linksVisibilitySchemaGenerator.UpdateSchemaForTopLevel(schemaType, fullSchema, schemaRepository); + + SetJsonApiVersion(fullSchema, schemaRepository); + + traceScope.TraceSucceeded(referenceSchema.Reference.Id); + return referenceSchema; + } + + protected abstract OpenApiSchema GenerateDocumentSchema(Type schemaType, SchemaRepository schemaRepository); + + private void SetJsonApiVersion(OpenApiSchema fullSchema, SchemaRepository schemaRepository) + { + if (fullSchema.Properties.ContainsKey(JsonApiPropertyName.Jsonapi) && !_options.IncludeJsonApiVersion) + { + fullSchema.Properties.Remove(JsonApiPropertyName.Jsonapi); + schemaRepository.Schemas.Remove(JsonApiPropertyName.Jsonapi); + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Documents/ErrorResponseDocumentSchemaGenerator.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Documents/ErrorResponseDocumentSchemaGenerator.cs new file mode 100644 index 0000000000..3d305e889b --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Documents/ErrorResponseDocumentSchemaGenerator.cs @@ -0,0 +1,60 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Documents; +using JsonApiDotNetCore.OpenApi.Swashbuckle.SchemaGenerators.Components; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.SchemaGenerators.Documents; + +/// +/// Generates the OpenAPI component schema for an error document. +/// +internal sealed class ErrorResponseDocumentSchemaGenerator : DocumentSchemaGenerator +{ + private static readonly Type ErrorObjectType = typeof(ErrorObject); + + private readonly SchemaGenerationTracer _schemaGenerationTracer; + private readonly SchemaGenerator _defaultSchemaGenerator; + private readonly MetaSchemaGenerator _metaSchemaGenerator; + + public ErrorResponseDocumentSchemaGenerator(SchemaGenerationTracer schemaGenerationTracer, SchemaGenerator defaultSchemaGenerator, + MetaSchemaGenerator metaSchemaGenerator, LinksVisibilitySchemaGenerator linksVisibilitySchemaGenerator, IJsonApiOptions options) + : base(schemaGenerationTracer, metaSchemaGenerator, linksVisibilitySchemaGenerator, options) + { + ArgumentNullException.ThrowIfNull(defaultSchemaGenerator); + + _schemaGenerationTracer = schemaGenerationTracer; + _defaultSchemaGenerator = defaultSchemaGenerator; + _metaSchemaGenerator = metaSchemaGenerator; + } + + public override bool CanGenerate(Type schemaType) + { + return schemaType == typeof(ErrorResponseDocument); + } + + protected override OpenApiSchema GenerateDocumentSchema(Type schemaType, SchemaRepository schemaRepository) + { + ArgumentNullException.ThrowIfNull(schemaType); + ArgumentNullException.ThrowIfNull(schemaRepository); + + OpenApiSchema referenceSchemaForErrorObject = GenerateSchemaForErrorObject(schemaRepository); + OpenApiSchema fullSchemaForErrorObject = schemaRepository.Schemas[referenceSchemaForErrorObject.Reference.Id]; + + OpenApiSchema referenceSchemaForMeta = _metaSchemaGenerator.GenerateSchema(schemaRepository); + fullSchemaForErrorObject.Properties[JsonApiPropertyName.Meta] = referenceSchemaForMeta.WrapInExtendedSchema(); + + return _defaultSchemaGenerator.GenerateSchema(schemaType, schemaRepository); + } + + private OpenApiSchema GenerateSchemaForErrorObject(SchemaRepository schemaRepository) + { + using ISchemaGenerationTraceScope traceScope = _schemaGenerationTracer.TraceStart(this, ErrorObjectType); + + OpenApiSchema referenceSchema = _defaultSchemaGenerator.GenerateSchema(ErrorObjectType, schemaRepository); + + traceScope.TraceSucceeded(referenceSchema.Reference.Id); + return referenceSchema; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Documents/ResourceOrRelationshipDocumentSchemaGenerator.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Documents/ResourceOrRelationshipDocumentSchemaGenerator.cs new file mode 100644 index 0000000000..767f0d0143 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/Documents/ResourceOrRelationshipDocumentSchemaGenerator.cs @@ -0,0 +1,80 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Documents; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Relationships; +using JsonApiDotNetCore.OpenApi.Swashbuckle.SchemaGenerators.Components; +using JsonApiDotNetCore.OpenApi.Swashbuckle.SwaggerComponents; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.SchemaGenerators.Documents; + +/// +/// Generates the OpenAPI component schema for a resource/relationship request/response document. +/// +internal sealed class ResourceOrRelationshipDocumentSchemaGenerator : DocumentSchemaGenerator +{ + private static readonly Type[] RequestDocumentSchemaTypes = + [ + typeof(CreateRequestDocument<>), + typeof(UpdateRequestDocument<>), + typeof(ToOneInRequest<>), + typeof(NullableToOneInRequest<>), + typeof(ToManyInRequest<>) + ]; + + private static readonly Type[] ResponseDocumentSchemaTypes = + [ + typeof(CollectionResponseDocument<>), + typeof(PrimaryResponseDocument<>), + typeof(SecondaryResponseDocument<>), + typeof(NullableSecondaryResponseDocument<>), + typeof(IdentifierResponseDocument<>), + typeof(NullableIdentifierResponseDocument<>), + typeof(IdentifierCollectionResponseDocument<>) + ]; + + private readonly SchemaGenerator _defaultSchemaGenerator; + private readonly DataContainerSchemaGenerator _dataContainerSchemaGenerator; + private readonly IResourceGraph _resourceGraph; + + public ResourceOrRelationshipDocumentSchemaGenerator(SchemaGenerationTracer schemaGenerationTracer, SchemaGenerator defaultSchemaGenerator, + DataContainerSchemaGenerator dataContainerSchemaGenerator, MetaSchemaGenerator metaSchemaGenerator, + LinksVisibilitySchemaGenerator linksVisibilitySchemaGenerator, IJsonApiOptions options, IResourceGraph resourceGraph) + : base(schemaGenerationTracer, metaSchemaGenerator, linksVisibilitySchemaGenerator, options) + { + ArgumentNullException.ThrowIfNull(defaultSchemaGenerator); + ArgumentNullException.ThrowIfNull(dataContainerSchemaGenerator); + ArgumentNullException.ThrowIfNull(resourceGraph); + + _defaultSchemaGenerator = defaultSchemaGenerator; + _dataContainerSchemaGenerator = dataContainerSchemaGenerator; + _resourceGraph = resourceGraph; + } + + public override bool CanGenerate(Type schemaType) + { + Type schemaOpenType = schemaType.ConstructedToOpenType(); + return RequestDocumentSchemaTypes.Contains(schemaOpenType) || ResponseDocumentSchemaTypes.Contains(schemaOpenType); + } + + protected override OpenApiSchema GenerateDocumentSchema(Type schemaType, SchemaRepository schemaRepository) + { + ArgumentNullException.ThrowIfNull(schemaType); + ArgumentNullException.ThrowIfNull(schemaRepository); + + var resourceSchemaType = ResourceSchemaType.Create(schemaType, _resourceGraph); + bool isRequestSchema = RequestDocumentSchemaTypes.Contains(resourceSchemaType.SchemaOpenType); + + _ = _dataContainerSchemaGenerator.GenerateSchema(schemaType, resourceSchemaType.ResourceType, isRequestSchema, !isRequestSchema, schemaRepository); + + OpenApiSchema? referenceSchemaForDocument = _defaultSchemaGenerator.GenerateSchema(schemaType, schemaRepository); + OpenApiSchema inlineSchemaForDocument = schemaRepository.Schemas[referenceSchemaForDocument.Reference.Id].UnwrapLastExtendedSchema(); + + if (JsonApiSchemaFacts.HasNullableDataProperty(resourceSchemaType.SchemaOpenType)) + { + inlineSchemaForDocument.Properties[JsonApiPropertyName.Data].Nullable = true; + } + + return referenceSchemaForDocument; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/GenerationCacheSchemaGenerator.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/GenerationCacheSchemaGenerator.cs new file mode 100644 index 0000000000..beba632ebf --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/GenerationCacheSchemaGenerator.cs @@ -0,0 +1,93 @@ +using System.Reflection; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.SchemaGenerators; + +/// +/// Provides access to cached state, which is stored in a temporary schema in during schema generation. +/// +internal sealed class GenerationCacheSchemaGenerator +{ + private const string HasAtomicOperationsEndpointPropertyName = "HasAtomicOperationsEndpoint"; + public const string SchemaId = "__JsonApiSchemaGenerationCache__"; + + private readonly SchemaGenerationTracer _schemaGenerationTracer; + private readonly IActionDescriptorCollectionProvider _defaultProvider; + private readonly JsonApiEndpointMetadataProvider _jsonApiEndpointMetadataProvider; + + public GenerationCacheSchemaGenerator(SchemaGenerationTracer schemaGenerationTracer, IActionDescriptorCollectionProvider defaultProvider, + JsonApiEndpointMetadataProvider jsonApiEndpointMetadataProvider) + { + ArgumentNullException.ThrowIfNull(schemaGenerationTracer); + ArgumentNullException.ThrowIfNull(defaultProvider); + ArgumentNullException.ThrowIfNull(jsonApiEndpointMetadataProvider); + + _schemaGenerationTracer = schemaGenerationTracer; + _defaultProvider = defaultProvider; + _jsonApiEndpointMetadataProvider = jsonApiEndpointMetadataProvider; + } + + public bool HasAtomicOperationsEndpoint(SchemaRepository schemaRepository) + { + ArgumentNullException.ThrowIfNull(schemaRepository); + + OpenApiSchema fullSchema = GenerateFullSchema(schemaRepository); + + var hasAtomicOperationsEndpoint = (OpenApiBoolean)fullSchema.Properties[HasAtomicOperationsEndpointPropertyName].Default; + return hasAtomicOperationsEndpoint.Value; + } + + private OpenApiSchema GenerateFullSchema(SchemaRepository schemaRepository) + { + if (schemaRepository.Schemas.TryGetValue(SchemaId, out OpenApiSchema? fullSchema)) + { + return fullSchema; + } + + using ISchemaGenerationTraceScope traceScope = _schemaGenerationTracer.TraceStart(this); + + bool hasAtomicOperationsEndpoint = EvaluateHasAtomicOperationsEndpoint(); + + fullSchema = new OpenApiSchema + { + Type = "object", + Properties = new Dictionary + { + [HasAtomicOperationsEndpointPropertyName] = new() + { + Type = "boolean", + Default = new OpenApiBoolean(hasAtomicOperationsEndpoint) + } + } + }; + + schemaRepository.AddDefinition(SchemaId, fullSchema); + + traceScope.TraceSucceeded(SchemaId); + return fullSchema; + } + + private bool EvaluateHasAtomicOperationsEndpoint() + { + IEnumerable actionDescriptors = + _defaultProvider.ActionDescriptors.Items.Where(JsonApiActionDescriptorCollectionProvider.IsVisibleJsonApiEndpoint); + + foreach (ActionDescriptor actionDescriptor in actionDescriptors) + { + MethodInfo actionMethod = actionDescriptor.GetActionMethod(); + JsonApiEndpointMetadataContainer endpointMetadataContainer = _jsonApiEndpointMetadataProvider.Get(actionMethod); + + if (endpointMetadataContainer.RequestMetadata is AtomicOperationsRequestMetadata) + { + return true; + } + } + + return false; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/JsonApiSchemaGenerator.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/JsonApiSchemaGenerator.cs new file mode 100644 index 0000000000..19d94eb48e --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaGenerators/JsonApiSchemaGenerator.cs @@ -0,0 +1,70 @@ +using System.Reflection; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.OpenApi.Swashbuckle.SchemaGenerators.Components; +using JsonApiDotNetCore.OpenApi.Swashbuckle.SchemaGenerators.Documents; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.SchemaGenerators; + +internal sealed class JsonApiSchemaGenerator : ISchemaGenerator +{ + private readonly ResourceIdSchemaGenerator _resourceIdSchemaGenerator; + private readonly DocumentSchemaGenerator[] _documentSchemaGenerators; + + public JsonApiSchemaGenerator(ResourceIdSchemaGenerator resourceIdSchemaGenerator, IEnumerable documentSchemaGenerators) + { + ArgumentNullException.ThrowIfNull(resourceIdSchemaGenerator); + ArgumentNullException.ThrowIfNull(documentSchemaGenerators); + + _resourceIdSchemaGenerator = resourceIdSchemaGenerator; + _documentSchemaGenerators = documentSchemaGenerators as DocumentSchemaGenerator[] ?? documentSchemaGenerators.ToArray(); + } + + public OpenApiSchema GenerateSchema(Type schemaType, SchemaRepository schemaRepository, MemberInfo? memberInfo = null, ParameterInfo? parameterInfo = null, + ApiParameterRouteInfo? routeInfo = null) + { + ArgumentNullException.ThrowIfNull(schemaType); + ArgumentNullException.ThrowIfNull(schemaRepository); + + if (parameterInfo is { Name: "id" } && IsJsonApiParameter(parameterInfo)) + { + return _resourceIdSchemaGenerator.GenerateSchema(schemaType, schemaRepository); + } + + DocumentSchemaGenerator schemaGenerator = GetDocumentSchemaGenerator(schemaType); + OpenApiSchema referenceSchema = schemaGenerator.GenerateSchema(schemaType, schemaRepository); + + if (memberInfo != null || parameterInfo != null) + { + // For unknown reasons, Swashbuckle chooses to wrap request bodies in allOf, but not response bodies. + // We just replicate that behavior here. See https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/861#issuecomment-1373631712. + referenceSchema = referenceSchema.WrapInExtendedSchema(); + } + + return referenceSchema; + } + + private static bool IsJsonApiParameter(ParameterInfo parameter) + { + return parameter.Member.DeclaringType != null && parameter.Member.DeclaringType.IsAssignableTo(typeof(CoreJsonApiController)); + } + + private DocumentSchemaGenerator GetDocumentSchemaGenerator(Type schemaType) + { + DocumentSchemaGenerator? generator = null; + + foreach (DocumentSchemaGenerator documentSchemaGenerator in _documentSchemaGenerators) + { + if (documentSchemaGenerator.CanGenerate(schemaType)) + { + generator = documentSchemaGenerator; + break; + } + } + + ConsistencyGuard.ThrowIf(generator == null); + return generator; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaRepositoryExtensions.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaRepositoryExtensions.cs new file mode 100644 index 0000000000..669c50b4e0 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SchemaRepositoryExtensions.cs @@ -0,0 +1,63 @@ +using System.Reflection; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle; + +internal static class SchemaRepositoryExtensions +{ + private const string ReservedIdsFieldName = "_reservedIds"; + private static readonly FieldInfo ReservedIdsField = GetReservedIdsField(); + + private static FieldInfo GetReservedIdsField() + { + FieldInfo? field = typeof(SchemaRepository).GetField(ReservedIdsFieldName, BindingFlags.Instance | BindingFlags.NonPublic); + + if (field == null) + { + throw new InvalidOperationException($"Failed to locate private field '{ReservedIdsFieldName}' " + + $"in type '{typeof(SchemaRepository).FullName}' in assembly '{typeof(SchemaRepository).Assembly.FullName}'."); + } + + if (field.FieldType != typeof(Dictionary)) + { + throw new InvalidOperationException($"Unexpected type '{field.FieldType}' of private field '{ReservedIdsFieldName}' " + + $"in type '{typeof(SchemaRepository).FullName}' in assembly '{typeof(SchemaRepository).Assembly.FullName}'."); + } + + return field; + } + + public static OpenApiSchema LookupByType(this SchemaRepository schemaRepository, Type schemaType) + { + ArgumentNullException.ThrowIfNull(schemaRepository); + ArgumentNullException.ThrowIfNull(schemaType); + + if (!schemaRepository.TryLookupByType(schemaType, out OpenApiSchema? referenceSchema)) + { + throw new InvalidOperationException($"Reference schema for '{schemaType.Name}' does not exist."); + } + + return referenceSchema; + } + + public static void ReplaceSchemaId(this SchemaRepository schemaRepository, Type oldSchemaType, string newSchemaId) + { + ArgumentNullException.ThrowIfNull(schemaRepository); + ArgumentNullException.ThrowIfNull(oldSchemaType); + ArgumentException.ThrowIfNullOrEmpty(newSchemaId); + + if (schemaRepository.TryLookupByType(oldSchemaType, out OpenApiSchema? referenceSchema)) + { + string oldSchemaId = referenceSchema.Reference.Id; + + OpenApiSchema fullSchema = schemaRepository.Schemas[oldSchemaId]; + + schemaRepository.Schemas.Remove(oldSchemaId); + schemaRepository.Schemas.Add(newSchemaId, fullSchema); + + var reservedIds = (Dictionary)ReservedIdsField.GetValue(schemaRepository)!; + reservedIds.Remove(oldSchemaType); + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ServiceCollectionExtensions.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..6872115289 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/ServiceCollectionExtensions.cs @@ -0,0 +1,123 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata; +using JsonApiDotNetCore.OpenApi.Swashbuckle.SchemaGenerators; +using JsonApiDotNetCore.OpenApi.Swashbuckle.SchemaGenerators.Components; +using JsonApiDotNetCore.OpenApi.Swashbuckle.SchemaGenerators.Documents; +using JsonApiDotNetCore.OpenApi.Swashbuckle.SwaggerComponents; +using JsonApiDotNetCore.Serialization.Response; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle; + +public static class ServiceCollectionExtensions +{ + /// + /// Configures OpenAPI for JsonApiDotNetCore using Swashbuckle. + /// + public static void AddOpenApiForJsonApi(this IServiceCollection services, Action? configureSwaggerGenOptions = null) + { + ArgumentNullException.ThrowIfNull(services); + AssertHasJsonApi(services); + + AddCustomApiExplorer(services); + AddCustomSwaggerComponents(services); + AddSwaggerGenerator(services); + + if (configureSwaggerGenOptions != null) + { + services.Configure(configureSwaggerGenOptions); + } + + services.AddSingleton(); + services.TryAddSingleton(); + services.Replace(ServiceDescriptor.Singleton()); + } + + private static void AssertHasJsonApi(IServiceCollection services) + { + if (services.FirstOrDefault(descriptor => descriptor.ServiceType == typeof(IJsonApiOptions)) == null) + { + throw new InvalidConfigurationException("Call 'services.AddJsonApi()' before calling 'services.AddOpenApiForJsonApi()'."); + } + } + + private static void AddCustomApiExplorer(IServiceCollection services) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + + // Not using TryAddSingleton, see https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1463. + services.Replace(ServiceDescriptor.Singleton(serviceProvider => + { + var actionDescriptorCollectionProvider = serviceProvider.GetRequiredService(); + var apiDescriptionProviders = serviceProvider.GetRequiredService>(); + + return new ApiDescriptionGroupCollectionProvider(actionDescriptorCollectionProvider, apiDescriptionProviders); + })); + + AddApiExplorer(services); + + services.AddSingleton, ConfigureMvcOptions>(); + } + + private static void AddApiExplorer(IServiceCollection services) + { + // The code below was copied from the implementation of MvcApiExplorerMvcCoreBuilderExtensions.AddApiExplorer(), + // so we don't need to take IMvcCoreBuilder as an input parameter. + + services.TryAddEnumerable(ServiceDescriptor.Transient()); + } + + private static void AddCustomSwaggerComponents(IServiceCollection services) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + } + + private static void AddSwaggerGenerator(IServiceCollection services) + { + AddSchemaGenerators(services); + + services.TryAddSingleton(); + services.AddSingleton(); + + services.AddSwaggerGen(); + services.AddSingleton, ConfigureSwaggerGenOptions>(); + } + + private static void AddSchemaGenerators(IServiceCollection services) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + + services.TryAddSingleton(); + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SetSchemaTypeToObjectDocumentFilter.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SetSchemaTypeToObjectDocumentFilter.cs new file mode 100644 index 0000000000..2764f868e6 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SetSchemaTypeToObjectDocumentFilter.cs @@ -0,0 +1,23 @@ +using JetBrains.Annotations; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +internal sealed class SetSchemaTypeToObjectDocumentFilter : IDocumentFilter +{ + internal const string RequiresRootObjectTypeKey = "x-requires-root-object-type"; + + public void Apply(OpenApiDocument document, DocumentFilterContext context) + { + foreach (OpenApiSchema schema in document.Components.Schemas.Values) + { + if (schema.Extensions.ContainsKey(RequiresRootObjectTypeKey)) + { + schema.Type = "object"; + schema.Extensions.Remove(RequiresRootObjectTypeKey); + } + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/DocumentationOpenApiOperationFilter.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/DocumentationOpenApiOperationFilter.cs new file mode 100644 index 0000000000..1b5c0d5f4c --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/DocumentationOpenApiOperationFilter.cs @@ -0,0 +1,596 @@ +using System.Net; +using System.Reflection; +using Humanizer; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.Net.Http.Headers; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.SwaggerComponents; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +internal sealed class DocumentationOpenApiOperationFilter : IOperationFilter +{ + private const string GetPrimaryName = nameof(BaseJsonApiController, int>.GetAsync); + private const string GetSecondaryName = nameof(BaseJsonApiController, int>.GetSecondaryAsync); + private const string GetRelationshipName = nameof(BaseJsonApiController, int>.GetRelationshipAsync); + private const string PostResourceName = nameof(BaseJsonApiController, int>.PostAsync); + private const string PostRelationshipName = nameof(BaseJsonApiController, int>.PostRelationshipAsync); + private const string PatchResourceName = nameof(BaseJsonApiController, int>.PatchAsync); + private const string PatchRelationshipName = nameof(BaseJsonApiController, int>.PatchRelationshipAsync); + private const string DeleteResourceName = nameof(BaseJsonApiController, int>.DeleteAsync); + private const string DeleteRelationshipName = nameof(BaseJsonApiController, int>.DeleteRelationshipAsync); + private const string PostOperationsName = nameof(BaseJsonApiOperationsController.PostOperationsAsync); + + private const string TextCompareETag = + "Compare the returned ETag HTTP header with an earlier one to determine if the response has changed since it was fetched."; + + private const string TextCompletedSuccessfully = "The operation completed successfully."; + private const string TextNotModified = "The fingerprint of the HTTP response matches one of the ETags from the incoming If-None-Match header."; + private const string TextQueryStringBad = "The query string is invalid."; + private const string TextRequestBodyBad = "The request body is missing or malformed."; + private const string TextQueryStringOrRequestBodyBad = "The query string is invalid or the request body is missing or malformed."; + private const string TextConflict = "The request body contains conflicting information or another resource with the same ID already exists."; + private const string TextRequestBodyIncompatibleIdOrType = "A resource type or identifier in the request body is incompatible."; + private const string TextRequestBodyValidationFailed = "Validation of the request body failed."; + private const string TextRequestBodyClientId = "Client-generated IDs cannot be used at this endpoint."; + + private const string ResourceQueryStringParameters = + "For syntax, see the documentation for the [`include`](https://www.jsonapi.net/usage/reading/including-relationships.html)/" + + "[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/" + + "[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/" + + "[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters."; + + private const string RelationshipQueryStringParameters = "For syntax, see the documentation for the " + + "[`filter`](https://www.jsonapi.net/usage/reading/filtering.html)/[`sort`](https://www.jsonapi.net/usage/reading/sorting.html)/" + + "[`page`](https://www.jsonapi.net/usage/reading/pagination.html)/" + + "[`fields`](https://www.jsonapi.net/usage/reading/sparse-fieldset-selection.html) query string parameters."; + + private readonly IJsonApiOptions _options; + private readonly IControllerResourceMapping _controllerResourceMapping; + private readonly ResourceFieldValidationMetadataProvider _resourceFieldValidationMetadataProvider; + + public DocumentationOpenApiOperationFilter(IJsonApiOptions options, IControllerResourceMapping controllerResourceMapping, + ResourceFieldValidationMetadataProvider resourceFieldValidationMetadataProvider) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(controllerResourceMapping); + ArgumentNullException.ThrowIfNull(resourceFieldValidationMetadataProvider); + + _options = options; + _controllerResourceMapping = controllerResourceMapping; + _resourceFieldValidationMetadataProvider = resourceFieldValidationMetadataProvider; + } + + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + ArgumentNullException.ThrowIfNull(operation); + ArgumentNullException.ThrowIfNull(context); + + bool hasHeadVerb = context.ApiDescription.HttpMethod == "HEAD"; + + if (hasHeadVerb) + { + operation.Responses.Clear(); + } + + MethodInfo actionMethod = context.ApiDescription.ActionDescriptor.GetActionMethod(); + string actionName = context.MethodInfo.Name; + ResourceType? resourceType = _controllerResourceMapping.GetResourceTypeForController(actionMethod.ReflectedType); + + if (resourceType != null) + { + switch (actionName) + { + case GetPrimaryName or PostResourceName or PatchResourceName or DeleteResourceName: + { + switch (actionName) + { + case GetPrimaryName: + { + ApplyGetPrimary(operation, resourceType, hasHeadVerb); + break; + } + case PostResourceName: + { + ApplyPostResource(operation, resourceType); + break; + } + case PatchResourceName: + { + ApplyPatchResource(operation, resourceType); + break; + } + case DeleteResourceName: + { + ApplyDeleteResource(operation, resourceType); + break; + } + } + + break; + } + case GetSecondaryName or GetRelationshipName or PostRelationshipName or PatchRelationshipName or DeleteRelationshipName: + { + RelationshipAttribute relationship = GetRelationshipFromRoute(context.ApiDescription, resourceType); + + switch (actionName) + { + case GetSecondaryName: + { + ApplyGetSecondary(operation, relationship, hasHeadVerb); + break; + } + case GetRelationshipName: + { + ApplyGetRelationship(operation, relationship, hasHeadVerb); + break; + } + case PostRelationshipName: + { + ApplyPostRelationship(operation, relationship); + break; + } + case PatchRelationshipName: + { + ApplyPatchRelationship(operation, relationship); + break; + } + case DeleteRelationshipName: + { + ApplyDeleteRelationship(operation, relationship); + break; + } + } + + break; + } + } + } + else if (actionName == PostOperationsName) + { + ApplyPostOperations(operation); + } + } + + private static void ApplyGetPrimary(OpenApiOperation operation, ResourceType resourceType, bool hasHeadVerb) + { + if (operation.Parameters.Count == 0) + { + if (hasHeadVerb) + { + SetOperationSummary(operation, $"Retrieves a collection of {resourceType} without returning them."); + SetOperationRemarks(operation, TextCompareETag); + SetResponseDescription(operation.Responses, HttpStatusCode.OK, TextCompletedSuccessfully); + SetResponseHeaderETag(operation.Responses, HttpStatusCode.OK); + SetResponseHeaderContentLength(operation.Responses, HttpStatusCode.OK); + SetResponseDescription(operation.Responses, HttpStatusCode.NotModified, TextNotModified); + SetResponseHeaderETag(operation.Responses, HttpStatusCode.NotModified); + } + else + { + SetOperationSummary(operation, $"Retrieves a collection of {resourceType}."); + + SetResponseDescription(operation.Responses, HttpStatusCode.OK, + $"Successfully returns the found {resourceType}, or an empty array if none were found."); + + SetResponseHeaderETag(operation.Responses, HttpStatusCode.OK); + SetResponseDescription(operation.Responses, HttpStatusCode.NotModified, TextNotModified); + SetResponseHeaderETag(operation.Responses, HttpStatusCode.NotModified); + } + + AddQueryStringParameters(operation, false); + AddRequestHeaderIfNoneMatch(operation); + SetResponseDescription(operation.Responses, HttpStatusCode.BadRequest, TextQueryStringBad); + } + else if (operation.Parameters.Count == 1) + { + string singularName = resourceType.PublicName.Singularize(); + + if (hasHeadVerb) + { + SetOperationSummary(operation, $"Retrieves an individual {singularName} by its identifier without returning it."); + SetOperationRemarks(operation, TextCompareETag); + SetResponseDescription(operation.Responses, HttpStatusCode.OK, TextCompletedSuccessfully); + SetResponseHeaderETag(operation.Responses, HttpStatusCode.OK); + SetResponseHeaderContentLength(operation.Responses, HttpStatusCode.OK); + SetResponseDescription(operation.Responses, HttpStatusCode.NotModified, TextNotModified); + SetResponseHeaderETag(operation.Responses, HttpStatusCode.NotModified); + } + else + { + SetOperationSummary(operation, $"Retrieves an individual {singularName} by its identifier."); + SetResponseDescription(operation.Responses, HttpStatusCode.OK, $"Successfully returns the found {singularName}."); + SetResponseHeaderETag(operation.Responses, HttpStatusCode.OK); + SetResponseDescription(operation.Responses, HttpStatusCode.NotModified, TextNotModified); + SetResponseHeaderETag(operation.Responses, HttpStatusCode.NotModified); + } + + SetParameterDescription(operation.Parameters[0], $"The identifier of the {singularName} to retrieve."); + AddQueryStringParameters(operation, false); + AddRequestHeaderIfNoneMatch(operation); + SetResponseDescription(operation.Responses, HttpStatusCode.BadRequest, TextQueryStringBad); + SetResponseDescription(operation.Responses, HttpStatusCode.NotFound, $"The {singularName} does not exist."); + } + } + + private void ApplyPostResource(OpenApiOperation operation, ResourceType resourceType) + { + string singularName = resourceType.PublicName.Singularize(); + + SetOperationSummary(operation, $"Creates a new {singularName}."); + AddQueryStringParameters(operation, false); + SetRequestBodyDescription(operation.RequestBody, $"The attributes and relationships of the {singularName} to create."); + + SetResponseDescription(operation.Responses, HttpStatusCode.Created, + $"The {singularName} was successfully created, which resulted in additional changes. The newly created {singularName} is returned."); + + SetResponseHeaderLocation(operation.Responses, HttpStatusCode.Created, singularName); + + SetResponseDescription(operation.Responses, HttpStatusCode.NoContent, + $"The {singularName} was successfully created, which did not result in additional changes."); + + SetResponseDescription(operation.Responses, HttpStatusCode.BadRequest, TextQueryStringOrRequestBodyBad); + + ClientIdGenerationMode clientIdGeneration = resourceType.ClientIdGeneration ?? _options.ClientIdGeneration; + + if (clientIdGeneration == ClientIdGenerationMode.Forbidden) + { + SetResponseDescription(operation.Responses, HttpStatusCode.Forbidden, TextRequestBodyClientId); + } + + SetResponseDescription(operation.Responses, HttpStatusCode.NotFound, "A related resource does not exist."); + SetResponseDescription(operation.Responses, HttpStatusCode.Conflict, TextConflict); + SetResponseDescription(operation.Responses, HttpStatusCode.UnprocessableEntity, TextRequestBodyValidationFailed); + } + + private void ApplyPatchResource(OpenApiOperation operation, ResourceType resourceType) + { + string singularName = resourceType.PublicName.Singularize(); + + SetOperationSummary(operation, $"Updates an existing {singularName}."); + SetParameterDescription(operation.Parameters[0], $"The identifier of the {singularName} to update."); + AddQueryStringParameters(operation, false); + + SetRequestBodyDescription(operation.RequestBody, + $"The attributes and relationships of the {singularName} to update. Omitted fields are left unchanged."); + + SetResponseDescription(operation.Responses, HttpStatusCode.OK, + $"The {singularName} was successfully updated, which resulted in additional changes. The updated {singularName} is returned."); + + SetResponseDescription(operation.Responses, HttpStatusCode.NoContent, + $"The {singularName} was successfully updated, which did not result in additional changes."); + + SetResponseDescription(operation.Responses, HttpStatusCode.BadRequest, TextQueryStringOrRequestBodyBad); + SetResponseDescription(operation.Responses, HttpStatusCode.NotFound, $"The {singularName} or a related resource does not exist."); + SetResponseDescription(operation.Responses, HttpStatusCode.Conflict, TextRequestBodyIncompatibleIdOrType); + SetResponseDescription(operation.Responses, HttpStatusCode.UnprocessableEntity, TextRequestBodyValidationFailed); + } + + private void ApplyDeleteResource(OpenApiOperation operation, ResourceType resourceType) + { + string singularName = resourceType.PublicName.Singularize(); + + SetOperationSummary(operation, $"Deletes an existing {singularName} by its identifier."); + SetParameterDescription(operation.Parameters[0], $"The identifier of the {singularName} to delete."); + SetResponseDescription(operation.Responses, HttpStatusCode.NoContent, $"The {singularName} was successfully deleted."); + SetResponseDescription(operation.Responses, HttpStatusCode.NotFound, $"The {singularName} does not exist."); + } + + private static void ApplyGetSecondary(OpenApiOperation operation, RelationshipAttribute relationship, bool hasHeadVerb) + { + string singularLeftName = relationship.LeftType.PublicName.Singularize(); + string rightName = relationship is HasOneAttribute ? relationship.RightType.PublicName.Singularize() : relationship.RightType.PublicName; + + if (hasHeadVerb) + { + SetOperationSummary(operation, + relationship is HasOneAttribute + ? $"Retrieves the related {rightName} of an individual {singularLeftName}'s {relationship} relationship without returning it." + : $"Retrieves the related {rightName} of an individual {singularLeftName}'s {relationship} relationship without returning them."); + + SetOperationRemarks(operation, TextCompareETag); + SetResponseDescription(operation.Responses, HttpStatusCode.OK, TextCompletedSuccessfully); + SetResponseHeaderETag(operation.Responses, HttpStatusCode.OK); + SetResponseHeaderContentLength(operation.Responses, HttpStatusCode.OK); + SetResponseDescription(operation.Responses, HttpStatusCode.NotModified, TextNotModified); + SetResponseHeaderETag(operation.Responses, HttpStatusCode.NotModified); + } + else + { + SetOperationSummary(operation, $"Retrieves the related {rightName} of an individual {singularLeftName}'s {relationship} relationship."); + + SetResponseDescription(operation.Responses, HttpStatusCode.OK, + relationship is HasOneAttribute + ? $"Successfully returns the found {rightName}, or null if it was not found." + : $"Successfully returns the found {rightName}, or an empty array if none were found."); + + SetResponseHeaderETag(operation.Responses, HttpStatusCode.OK); + SetResponseDescription(operation.Responses, HttpStatusCode.NotModified, TextNotModified); + SetResponseHeaderETag(operation.Responses, HttpStatusCode.NotModified); + } + + SetParameterDescription(operation.Parameters[0], $"The identifier of the {singularLeftName} whose related {rightName} to retrieve."); + AddQueryStringParameters(operation, false); + AddRequestHeaderIfNoneMatch(operation); + SetResponseDescription(operation.Responses, HttpStatusCode.BadRequest, TextQueryStringBad); + SetResponseDescription(operation.Responses, HttpStatusCode.NotFound, $"The {singularLeftName} does not exist."); + } + + private static void ApplyGetRelationship(OpenApiOperation operation, RelationshipAttribute relationship, bool hasHeadVerb) + { + string singularLeftName = relationship.LeftType.PublicName.Singularize(); + string singularRightName = relationship.RightType.PublicName.Singularize(); + string ident = relationship is HasOneAttribute ? "identity" : "identities"; + + if (hasHeadVerb) + { + SetOperationSummary(operation, + relationship is HasOneAttribute + ? $"Retrieves the related {singularRightName} {ident} of an individual {singularLeftName}'s {relationship} relationship without returning it." + : $"Retrieves the related {singularRightName} {ident} of an individual {singularLeftName}'s {relationship} relationship without returning them."); + + SetOperationRemarks(operation, TextCompareETag); + SetResponseDescription(operation.Responses, HttpStatusCode.OK, TextCompletedSuccessfully); + SetResponseHeaderETag(operation.Responses, HttpStatusCode.OK); + SetResponseHeaderContentLength(operation.Responses, HttpStatusCode.OK); + SetResponseDescription(operation.Responses, HttpStatusCode.NotModified, TextNotModified); + SetResponseHeaderETag(operation.Responses, HttpStatusCode.NotModified); + } + else + { + SetOperationSummary(operation, + $"Retrieves the related {singularRightName} {ident} of an individual {singularLeftName}'s {relationship} relationship."); + + SetResponseDescription(operation.Responses, HttpStatusCode.OK, + relationship is HasOneAttribute + ? $"Successfully returns the found {singularRightName} {ident}, or null if it was not found." + : $"Successfully returns the found {singularRightName} {ident}, or an empty array if none were found."); + + SetResponseHeaderETag(operation.Responses, HttpStatusCode.OK); + SetResponseDescription(operation.Responses, HttpStatusCode.NotModified, TextNotModified); + SetResponseHeaderETag(operation.Responses, HttpStatusCode.NotModified); + } + + SetParameterDescription(operation.Parameters[0], $"The identifier of the {singularLeftName} whose related {singularRightName} {ident} to retrieve."); + AddQueryStringParameters(operation, true); + AddRequestHeaderIfNoneMatch(operation); + SetResponseDescription(operation.Responses, HttpStatusCode.BadRequest, TextQueryStringBad); + SetResponseDescription(operation.Responses, HttpStatusCode.NotFound, $"The {singularLeftName} does not exist."); + } + + private void ApplyPostRelationship(OpenApiOperation operation, RelationshipAttribute relationship) + { + string singularLeftName = relationship.LeftType.PublicName.Singularize(); + string rightName = relationship.RightType.PublicName; + + SetOperationSummary(operation, $"Adds existing {rightName} to the {relationship} relationship of an individual {singularLeftName}."); + SetParameterDescription(operation.Parameters[0], $"The identifier of the {singularLeftName} to add {rightName} to."); + SetRequestBodyDescription(operation.RequestBody, $"The identities of the {rightName} to add to the {relationship} relationship."); + + SetResponseDescription(operation.Responses, HttpStatusCode.NoContent, + $"The {rightName} were successfully added, which did not result in additional changes."); + + SetResponseDescription(operation.Responses, HttpStatusCode.BadRequest, TextRequestBodyBad); + SetResponseDescription(operation.Responses, HttpStatusCode.NotFound, $"The {singularLeftName} or a related resource does not exist."); + SetResponseDescription(operation.Responses, HttpStatusCode.Conflict, TextConflict); + SetResponseDescription(operation.Responses, HttpStatusCode.UnprocessableEntity, TextRequestBodyValidationFailed); + } + + private void ApplyPatchRelationship(OpenApiOperation operation, RelationshipAttribute relationship) + { + bool isOptional = _resourceFieldValidationMetadataProvider.IsNullable(relationship); + string singularLeftName = relationship.LeftType.PublicName.Singularize(); + string rightName = relationship is HasOneAttribute ? relationship.RightType.PublicName.Singularize() : relationship.RightType.PublicName; + + SetOperationSummary(operation, + relationship is HasOneAttribute + ? isOptional + ? $"Clears or assigns an existing {rightName} to the {relationship} relationship of an individual {singularLeftName}." + : $"Assigns an existing {rightName} to the {relationship} relationship of an individual {singularLeftName}." + : $"Assigns existing {rightName} to the {relationship} relationship of an individual {singularLeftName}."); + + SetParameterDescription(operation.Parameters[0], + isOptional + ? $"The identifier of the {singularLeftName} whose {relationship} relationship to assign or clear." + : $"The identifier of the {singularLeftName} whose {relationship} relationship to assign."); + + SetRequestBodyDescription(operation.RequestBody, + relationship is HasOneAttribute + ? isOptional + ? $"The identity of the {rightName} to assign to the {relationship} relationship, or null to clear the relationship." + : $"The identity of the {rightName} to assign to the {relationship} relationship." + : $"The identities of the {rightName} to assign to the {relationship} relationship, or an empty array to clear the relationship."); + + SetResponseDescription(operation.Responses, HttpStatusCode.NoContent, + $"The {relationship} relationship was successfully updated, which did not result in additional changes."); + + SetResponseDescription(operation.Responses, HttpStatusCode.BadRequest, TextRequestBodyBad); + SetResponseDescription(operation.Responses, HttpStatusCode.NotFound, $"The {singularLeftName} or a related resource does not exist."); + SetResponseDescription(operation.Responses, HttpStatusCode.Conflict, TextConflict); + SetResponseDescription(operation.Responses, HttpStatusCode.UnprocessableEntity, TextRequestBodyValidationFailed); + } + + private void ApplyDeleteRelationship(OpenApiOperation operation, RelationshipAttribute relationship) + { + string singularLeftName = relationship.LeftType.PublicName.Singularize(); + string rightName = relationship.RightType.PublicName; + + SetOperationSummary(operation, $"Removes existing {rightName} from the {relationship} relationship of an individual {singularLeftName}."); + SetParameterDescription(operation.Parameters[0], $"The identifier of the {singularLeftName} to remove {rightName} from."); + SetRequestBodyDescription(operation.RequestBody, $"The identities of the {rightName} to remove from the {relationship} relationship."); + + SetResponseDescription(operation.Responses, HttpStatusCode.NoContent, + $"The {rightName} were successfully removed, which did not result in additional changes."); + + SetResponseDescription(operation.Responses, HttpStatusCode.BadRequest, TextRequestBodyBad); + SetResponseDescription(operation.Responses, HttpStatusCode.NotFound, $"The {singularLeftName} or a related resource does not exist."); + SetResponseDescription(operation.Responses, HttpStatusCode.Conflict, TextConflict); + SetResponseDescription(operation.Responses, HttpStatusCode.UnprocessableEntity, TextRequestBodyValidationFailed); + } + + private static RelationshipAttribute GetRelationshipFromRoute(ApiDescription apiDescription, ResourceType resourceType) + { + ConsistencyGuard.ThrowIf(apiDescription.RelativePath == null); + + string relationshipName = apiDescription.RelativePath.Split('/').Last(); + return resourceType.GetRelationshipByPublicName(relationshipName); + } + + private static void SetOperationSummary(OpenApiOperation operation, string description) + { + operation.Summary = XmlCommentsTextHelper.Humanize(description); + } + + private static void SetOperationRemarks(OpenApiOperation operation, string description) + { + operation.Description = XmlCommentsTextHelper.Humanize(description); + } + + private static void SetParameterDescription(OpenApiParameter parameter, string description) + { + parameter.Description = XmlCommentsTextHelper.Humanize(description); + } + + private static void SetRequestBodyDescription(OpenApiRequestBody requestBody, string description) + { + requestBody.Description = XmlCommentsTextHelper.Humanize(description); + } + + private static void SetResponseDescription(OpenApiResponses responses, HttpStatusCode statusCode, string description) + { + OpenApiResponse response = GetOrAddResponse(responses, statusCode); + response.Description = XmlCommentsTextHelper.Humanize(description); + } + + private static void SetResponseHeaderETag(OpenApiResponses responses, HttpStatusCode statusCode) + { + OpenApiResponse response = GetOrAddResponse(responses, statusCode); + + response.Headers[HeaderNames.ETag] = new OpenApiHeader + { + Description = "A fingerprint of the HTTP response, which can be used in an If-None-Match header to only fetch changes.", + Required = true, + Schema = new OpenApiSchema + { + Type = "string" + } + }; + } + + private static void SetResponseHeaderContentLength(OpenApiResponses responses, HttpStatusCode statusCode) + { + OpenApiResponse response = GetOrAddResponse(responses, statusCode); + + response.Headers[HeaderNames.ContentLength] = new OpenApiHeader + { + Description = "Size of the HTTP response body, in bytes.", + Required = true, + Schema = new OpenApiSchema + { + Type = "integer", + Format = "int64" + } + }; + } + + private static void SetResponseHeaderLocation(OpenApiResponses responses, HttpStatusCode statusCode, string resourceName) + { + OpenApiResponse response = GetOrAddResponse(responses, statusCode); + + response.Headers[HeaderNames.Location] = new OpenApiHeader + { + Description = $"The URL at which the newly created {resourceName} can be retrieved.", + Required = true, + Schema = new OpenApiSchema + { + Type = "string", + Format = "uri" + } + }; + } + + private static OpenApiResponse GetOrAddResponse(OpenApiResponses responses, HttpStatusCode statusCode) + { + string responseCode = ((int)statusCode).ToString(); + + if (!responses.TryGetValue(responseCode, out OpenApiResponse? response)) + { + response = new OpenApiResponse(); + responses.Add(responseCode, response); + } + + return response; + } + + private static void AddQueryStringParameters(OpenApiOperation operation, bool isRelationshipEndpoint) + { + // The JSON:API query string parameters (include, filter, sort, page[size], page[number], fields[]) are too dynamic to represent in OpenAPI. + // - The parameter names for fields[] require exploding to all resource types, because outcome of possible resource types depends on + // the relationship chains in include, which are provided at invocation time. + // - The parameter names for filter/sort take a relationship path, which could be infinite. For example: ?filter[node.parent.parent.parent...]=... + + // The next best thing is to expose the query string parameters as unstructured and optional. + // - This makes SwaggerUI ask for JSON, which is a bit odd, but it works. For example: {"sort":"-id"} produces: ?sort=-id + // - This makes NSwag produce a C# client with method signature: GetAsync(IDictionary? query) + // when combined with true in the project file. + + operation.Parameters.Add(new OpenApiParameter + { + In = ParameterLocation.Query, + Name = "query", + Schema = new OpenApiSchema + { + Type = "object", + AdditionalProperties = new OpenApiSchema + { + Type = "string", + Nullable = true + }, + // Prevent SwaggerUI from producing sample, which fails when used because unknown query string parameters are blocked by default. + Example = new OpenApiString(string.Empty) + }, + Description = isRelationshipEndpoint ? RelationshipQueryStringParameters : ResourceQueryStringParameters + }); + } + + private static void AddRequestHeaderIfNoneMatch(OpenApiOperation operation) + { + operation.Parameters.Add(new OpenApiParameter + { + In = ParameterLocation.Header, + Name = "If-None-Match", + Description = "A list of ETags, resulting in HTTP status 304 without a body, if one of them matches the current fingerprint.", + Schema = new OpenApiSchema + { + Type = "string" + } + }); + } + + private static void ApplyPostOperations(OpenApiOperation operation) + { + SetOperationSummary(operation, "Performs multiple mutations in a linear and atomic manner."); + + SetRequestBodyDescription(operation.RequestBody, + "An array of mutation operations. For syntax, see the [Atomic Operations documentation](https://jsonapi.org/ext/atomic/)."); + + SetResponseDescription(operation.Responses, HttpStatusCode.OK, "All operations were successfully applied, which resulted in additional changes."); + + SetResponseDescription(operation.Responses, HttpStatusCode.NoContent, + "All operations were successfully applied, which did not result in additional changes."); + + SetResponseDescription(operation.Responses, HttpStatusCode.BadRequest, TextRequestBodyBad); + SetResponseDescription(operation.Responses, HttpStatusCode.Forbidden, "An operation is not accessible or a client-generated ID is used."); + SetResponseDescription(operation.Responses, HttpStatusCode.NotFound, "A resource or a related resource does not exist."); + SetResponseDescription(operation.Responses, HttpStatusCode.Conflict, TextConflict); + SetResponseDescription(operation.Responses, HttpStatusCode.UnprocessableEntity, TextRequestBodyValidationFailed); + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/EndpointOrderingFilter.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/EndpointOrderingFilter.cs new file mode 100644 index 0000000000..047ba4355a --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/EndpointOrderingFilter.cs @@ -0,0 +1,46 @@ +using System.Text.RegularExpressions; +using JetBrains.Annotations; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.SwaggerComponents; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +internal sealed partial class EndpointOrderingFilter : IDocumentFilter +{ + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) + { + ArgumentNullException.ThrowIfNull(swaggerDoc); + ArgumentNullException.ThrowIfNull(context); + + KeyValuePair[] endpointsInOrder = swaggerDoc.Paths.OrderBy(GetPrimaryResourcePublicName) + .ThenBy(GetRelationshipName).ThenBy(IsSecondaryEndpoint).ToArray(); + + swaggerDoc.Paths.Clear(); + + foreach ((string url, OpenApiPathItem path) in endpointsInOrder) + { + swaggerDoc.Paths.Add(url, path); + } + } + + private static string GetPrimaryResourcePublicName(KeyValuePair entry) + { + return entry.Value.Operations.First().Value.Tags.First().Name; + } + + private static bool IsSecondaryEndpoint(KeyValuePair entry) + { + return entry.Key.Contains("/relationships"); + } + + private static string GetRelationshipName(KeyValuePair entry) + { + Match match = RelationshipNameInUrlRegex().Match(entry.Key); + + return match.Success ? match.Groups["RelationshipName"].Value : string.Empty; + } + + [GeneratedRegex(@".*{id}/(?>relationships\/)?(?\w+)", RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture)] + private static partial Regex RelationshipNameInUrlRegex(); +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/JsonApiDataContractResolver.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/JsonApiDataContractResolver.cs new file mode 100644 index 0000000000..dcc652c696 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/JsonApiDataContractResolver.cs @@ -0,0 +1,99 @@ +using System.Reflection; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.SwaggerComponents; + +/// +/// For schema generation, we rely on from Swashbuckle for all but our own JSON:API types. +/// +internal sealed class JsonApiDataContractResolver : ISerializerDataContractResolver +{ + private readonly IResourceGraph _resourceGraph; + private readonly JsonSerializerDataContractResolver _dataContractResolver; + + public JsonApiDataContractResolver(IJsonApiOptions options, IResourceGraph resourceGraph) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(resourceGraph); + + _resourceGraph = resourceGraph; + _dataContractResolver = new JsonSerializerDataContractResolver(options.SerializerOptions); + } + + public DataContract GetDataContractForType(Type type) + { + ArgumentNullException.ThrowIfNull(type); + + if (type == typeof(IIdentifiable)) + { + // We have no way of telling Swashbuckle to opt out on this type, the closest we can get is return a contract with type Unknown. + return DataContract.ForDynamic(typeof(object)); + } + + DataContract dataContract = _dataContractResolver.GetDataContractForType(type); + + IList? replacementProperties = null; + + if (type.IsAssignableTo(typeof(IIdentifiable))) + { + replacementProperties = GetDataPropertiesThatExistInResourceClrType(type, dataContract); + } + + if (replacementProperties != null) + { + dataContract = ReplacePropertiesInDataContract(dataContract, replacementProperties); + } + + return dataContract; + } + + private static DataContract ReplacePropertiesInDataContract(DataContract dataContract, IEnumerable dataProperties) + { + return DataContract.ForObject(dataContract.UnderlyingType, dataProperties, dataContract.ObjectExtensionDataType, dataContract.ObjectTypeNameProperty, + dataContract.ObjectTypeNameValue); + } + + private List GetDataPropertiesThatExistInResourceClrType(Type resourceClrType, DataContract dataContract) + { + ResourceType resourceType = _resourceGraph.GetResourceType(resourceClrType); + List dataProperties = []; + + foreach (DataProperty property in dataContract.ObjectProperties) + { + if (property.MemberInfo.Name == nameof(Identifiable.Id)) + { + // Schemas of JsonApiDotNetCore resources will obtain an Id property through inheritance of a resource identifier type. + continue; + } + + ResourceFieldAttribute? matchingField = resourceType.Fields.SingleOrDefault(field => + IsPropertyCompatibleWithMember(field.Property, property.MemberInfo)); + + if (matchingField != null) + { + DataProperty matchingProperty = matchingField.PublicName != property.Name + ? ChangeDataPropertyName(property, matchingField.PublicName) + : property; + + dataProperties.Add(matchingProperty); + } + } + + return dataProperties; + } + + private static DataProperty ChangeDataPropertyName(DataProperty property, string name) + { + return new DataProperty(name, property.MemberType, property.IsRequired, property.IsNullable, property.IsReadOnly, property.IsWriteOnly, + property.MemberInfo); + } + + private static bool IsPropertyCompatibleWithMember(PropertyInfo property, MemberInfo member) + { + // In JsonApiDotNetCore the PropertyInfo for Id stored in AttrAttribute is that of the ReflectedType, whereas Newtonsoft uses the DeclaringType. + return property == member || property.DeclaringType?.GetProperty(property.Name) == member; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/ResourceDocumentationReader.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/ResourceDocumentationReader.cs new file mode 100644 index 0000000000..cd2727854c --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/ResourceDocumentationReader.cs @@ -0,0 +1,89 @@ +using System.Collections.Concurrent; +using System.Reflection; +using System.Xml; +using System.Xml.XPath; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.SwaggerComponents; + +internal sealed class ResourceDocumentationReader +{ + private static readonly ConcurrentDictionary NavigatorsByAssemblyPath = new(); + + public string? GetDocumentationForType(ResourceType resourceType) + { + ArgumentNullException.ThrowIfNull(resourceType); + + XPathNavigator? navigator = GetNavigator(resourceType.ClrType.Assembly); + + if (navigator != null) + { + string typeMemberName = XmlCommentsNodeNameHelper.GetMemberNameForType(resourceType.ClrType); + return GetSummary(navigator, typeMemberName); + } + + return null; + } + + public string? GetDocumentationForAttribute(AttrAttribute attribute) + { + ArgumentNullException.ThrowIfNull(attribute); + + XPathNavigator? navigator = GetNavigator(attribute.Type.ClrType.Assembly); + + if (navigator != null) + { + string propertyMemberName = XmlCommentsNodeNameHelper.GetMemberNameForFieldOrProperty(attribute.Property); + return GetSummary(navigator, propertyMemberName); + } + + return null; + } + + public string? GetDocumentationForRelationship(RelationshipAttribute relationship) + { + ArgumentNullException.ThrowIfNull(relationship); + + XPathNavigator? navigator = GetNavigator(relationship.Type.ClrType.Assembly); + + if (navigator != null) + { + string propertyMemberName = XmlCommentsNodeNameHelper.GetMemberNameForFieldOrProperty(relationship.Property); + return GetSummary(navigator, propertyMemberName); + } + + return null; + } + + private static XPathNavigator? GetNavigator(Assembly assembly) + { + string assemblyPath = assembly.Location; + + if (!string.IsNullOrEmpty(assemblyPath)) + { + return NavigatorsByAssemblyPath.GetOrAdd(assemblyPath, path => + { + string documentationPath = Path.ChangeExtension(path, ".xml"); + + if (File.Exists(documentationPath)) + { + using var reader = XmlReader.Create(documentationPath); + var document = new XPathDocument(reader); + return document.CreateNavigator(); + } + + return null; + }); + } + + return null; + } + + private string? GetSummary(XPathNavigator navigator, string memberName) + { + XPathNavigator? summaryNode = navigator.SelectSingleNode($"/doc/members/member[@name='{memberName}']/summary"); + return summaryNode != null ? XmlCommentsTextHelper.Humanize(summaryNode.InnerXml) : null; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/ResourceFieldSchemaBuilder.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/ResourceFieldSchemaBuilder.cs new file mode 100644 index 0000000000..31bf7be625 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/ResourceFieldSchemaBuilder.cs @@ -0,0 +1,250 @@ +using System.Reflection; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiMetadata; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.OpenApi.Swashbuckle.SchemaGenerators.Components; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.SwaggerComponents; + +internal sealed class ResourceFieldSchemaBuilder +{ + private readonly SchemaGenerationTracer _schemaGenerationTracer; + private readonly SchemaGenerator _defaultSchemaGenerator; + private readonly DataSchemaGenerator _dataSchemaGenerator; + private readonly LinksVisibilitySchemaGenerator _linksVisibilitySchemaGenerator; + private readonly ResourceSchemaType _resourceSchemaType; + private readonly ResourceFieldValidationMetadataProvider _resourceFieldValidationMetadataProvider; + private readonly RelationshipTypeFactory _relationshipTypeFactory; + + private readonly SchemaRepository _resourceSchemaRepository = new(); + private readonly ResourceDocumentationReader _resourceDocumentationReader = new(); + private readonly IDictionary _schemasForResourceFields; + + public ResourceFieldSchemaBuilder(SchemaGenerationTracer schemaGenerationTracer, SchemaGenerator defaultSchemaGenerator, + DataSchemaGenerator dataSchemaGenerator, LinksVisibilitySchemaGenerator linksVisibilitySchemaGenerator, + ResourceFieldValidationMetadataProvider resourceFieldValidationMetadataProvider, RelationshipTypeFactory relationshipTypeFactory, + ResourceSchemaType resourceSchemaType) + { + ArgumentNullException.ThrowIfNull(schemaGenerationTracer); + ArgumentNullException.ThrowIfNull(defaultSchemaGenerator); + ArgumentNullException.ThrowIfNull(dataSchemaGenerator); + ArgumentNullException.ThrowIfNull(linksVisibilitySchemaGenerator); + ArgumentNullException.ThrowIfNull(resourceSchemaType); + ArgumentNullException.ThrowIfNull(resourceFieldValidationMetadataProvider); + ArgumentNullException.ThrowIfNull(relationshipTypeFactory); + + _schemaGenerationTracer = schemaGenerationTracer; + _defaultSchemaGenerator = defaultSchemaGenerator; + _dataSchemaGenerator = dataSchemaGenerator; + _linksVisibilitySchemaGenerator = linksVisibilitySchemaGenerator; + _resourceSchemaType = resourceSchemaType; + _resourceFieldValidationMetadataProvider = resourceFieldValidationMetadataProvider; + _relationshipTypeFactory = relationshipTypeFactory; + + _schemasForResourceFields = GetFieldSchemas(); + } + + private IDictionary GetFieldSchemas() + { + if (!_resourceSchemaRepository.TryLookupByType(_resourceSchemaType.ResourceType.ClrType, out OpenApiSchema referenceSchemaForResource)) + { + referenceSchemaForResource = _defaultSchemaGenerator.GenerateSchema(_resourceSchemaType.ResourceType.ClrType, _resourceSchemaRepository); + } + + OpenApiSchema inlineSchemaForResource = _resourceSchemaRepository.Schemas[referenceSchemaForResource.Reference.Id].UnwrapLastExtendedSchema(); + return inlineSchemaForResource.Properties; + } + + public void SetMembersOfAttributes(OpenApiSchema fullSchemaForAttributes, bool forRequestSchema, SchemaRepository schemaRepository) + { + ArgumentNullException.ThrowIfNull(fullSchemaForAttributes); + ArgumentNullException.ThrowIfNull(schemaRepository); + AssertHasNoProperties(fullSchemaForAttributes); + + AttrCapabilities requiredCapability = GetRequiredCapabilityForAttributes(_resourceSchemaType.SchemaOpenType); + + foreach ((string publicName, OpenApiSchema schemaForResourceField) in _schemasForResourceFields) + { + AttrAttribute? matchingAttribute = _resourceSchemaType.ResourceType.FindAttributeByPublicName(publicName); + + if (matchingAttribute != null && matchingAttribute.Capabilities.HasFlag(requiredCapability)) + { + if (forRequestSchema) + { + if (matchingAttribute.Property.SetMethod == null) + { + continue; + } + } + else + { + if (matchingAttribute.Property.GetMethod == null) + { + continue; + } + } + + bool isInlineSchemaType = schemaForResourceField.AllOf.Count == 0; + + // Schemas for types like enum and complex attributes are handled as reference schemas. + if (!isInlineSchemaType) + { + OpenApiSchema referenceSchemaForAttribute = schemaForResourceField.UnwrapLastExtendedSchema(); + EnsureAttributeSchemaIsExposed(referenceSchemaForAttribute, matchingAttribute, schemaRepository); + } + + fullSchemaForAttributes.Properties.Add(matchingAttribute.PublicName, schemaForResourceField); + + schemaForResourceField.Nullable = _resourceFieldValidationMetadataProvider.IsNullable(matchingAttribute); + + if (IsFieldRequired(matchingAttribute)) + { + fullSchemaForAttributes.Required.Add(matchingAttribute.PublicName); + } + + schemaForResourceField.Description = _resourceDocumentationReader.GetDocumentationForAttribute(matchingAttribute); + } + } + } + + private static AttrCapabilities GetRequiredCapabilityForAttributes(Type resourceDataOpenType) + { + AttrCapabilities? capabilities = null; + + if (resourceDataOpenType == typeof(DataInResponse<>)) + { + capabilities = AttrCapabilities.AllowView; + } + else if (resourceDataOpenType == typeof(DataInCreateRequest<>)) + { + capabilities = AttrCapabilities.AllowCreate; + } + else if (resourceDataOpenType == typeof(DataInUpdateRequest<>)) + { + capabilities = AttrCapabilities.AllowChange; + } + + ConsistencyGuard.ThrowIf(capabilities == null); + return capabilities.Value; + } + + private void EnsureAttributeSchemaIsExposed(OpenApiSchema referenceSchemaForAttribute, AttrAttribute attribute, SchemaRepository schemaRepository) + { + Type nonNullableTypeInPropertyType = GetRepresentedTypeForAttributeSchema(attribute); + + if (schemaRepository.TryLookupByType(nonNullableTypeInPropertyType, out _)) + { + return; + } + + using ISchemaGenerationTraceScope traceScope = _schemaGenerationTracer.TraceStart(this, nonNullableTypeInPropertyType); + + string schemaId = referenceSchemaForAttribute.Reference.Id; + OpenApiSchema fullSchema = _resourceSchemaRepository.Schemas[schemaId]; + + schemaRepository.AddDefinition(schemaId, fullSchema); + schemaRepository.RegisterType(nonNullableTypeInPropertyType, schemaId); + + traceScope.TraceSucceeded(schemaId); + } + + private Type GetRepresentedTypeForAttributeSchema(AttrAttribute attribute) + { + NullabilityInfoContext nullabilityInfoContext = new(); + NullabilityInfo attributeNullabilityInfo = nullabilityInfoContext.Create(attribute.Property); + + bool isNullable = attributeNullabilityInfo is { ReadState: NullabilityState.Nullable, WriteState: NullabilityState.Nullable }; + + Type nonNullableTypeInPropertyType = isNullable + ? Nullable.GetUnderlyingType(attribute.Property.PropertyType) ?? attribute.Property.PropertyType + : attribute.Property.PropertyType; + + return nonNullableTypeInPropertyType; + } + + private bool IsFieldRequired(ResourceFieldAttribute field) + { + bool isCreateResourceSchemaType = _resourceSchemaType.SchemaOpenType == typeof(DataInCreateRequest<>); + return isCreateResourceSchemaType && _resourceFieldValidationMetadataProvider.IsRequired(field); + } + + public void SetMembersOfRelationships(OpenApiSchema fullSchemaForRelationships, bool forRequestSchema, SchemaRepository schemaRepository) + { + ArgumentNullException.ThrowIfNull(fullSchemaForRelationships); + ArgumentNullException.ThrowIfNull(schemaRepository); + AssertHasNoProperties(fullSchemaForRelationships); + + foreach (string publicName in _schemasForResourceFields.Keys) + { + RelationshipAttribute? matchingRelationship = _resourceSchemaType.ResourceType.FindRelationshipByPublicName(publicName); + + if (matchingRelationship != null) + { + Type identifierSchemaOpenType = forRequestSchema ? typeof(IdentifierInRequest<>) : typeof(IdentifierInResponse<>); + Type identifierSchemaConstructedType = identifierSchemaOpenType.MakeGenericType(matchingRelationship.RightType.ClrType); + + _ = _dataSchemaGenerator.GenerateSchema(identifierSchemaConstructedType, forRequestSchema, schemaRepository); + AddRelationshipSchemaToResourceData(matchingRelationship, fullSchemaForRelationships, schemaRepository); + } + } + } + + private void AddRelationshipSchemaToResourceData(RelationshipAttribute relationship, OpenApiSchema fullSchemaForRelationships, + SchemaRepository schemaRepository) + { + Type relationshipSchemaType = GetRelationshipSchemaType(relationship, _resourceSchemaType.SchemaOpenType); + + OpenApiSchema referenceSchemaForRelationship = GetReferenceSchemaForRelationship(relationshipSchemaType, schemaRepository) ?? + CreateReferenceSchemaForRelationship(relationshipSchemaType, schemaRepository); + + OpenApiSchema extendedReferenceSchemaForRelationship = referenceSchemaForRelationship.WrapInExtendedSchema(); + extendedReferenceSchemaForRelationship.Description = _resourceDocumentationReader.GetDocumentationForRelationship(relationship); + + fullSchemaForRelationships.Properties.Add(relationship.PublicName, extendedReferenceSchemaForRelationship); + + if (IsFieldRequired(relationship)) + { + fullSchemaForRelationships.Required.Add(relationship.PublicName); + } + } + + private Type GetRelationshipSchemaType(RelationshipAttribute relationship, Type openSchemaType) + { + bool isResponseSchemaType = openSchemaType.IsAssignableTo(typeof(DataInResponse<>)); + return isResponseSchemaType ? _relationshipTypeFactory.GetForResponse(relationship) : _relationshipTypeFactory.GetForRequest(relationship); + } + + private OpenApiSchema? GetReferenceSchemaForRelationship(Type relationshipSchemaType, SchemaRepository schemaRepository) + { + return schemaRepository.TryLookupByType(relationshipSchemaType, out OpenApiSchema? referenceSchema) ? referenceSchema : null; + } + + private OpenApiSchema CreateReferenceSchemaForRelationship(Type relationshipSchemaType, SchemaRepository schemaRepository) + { + using ISchemaGenerationTraceScope traceScope = _schemaGenerationTracer.TraceStart(this, relationshipSchemaType); + + OpenApiSchema referenceSchema = _defaultSchemaGenerator.GenerateSchema(relationshipSchemaType, schemaRepository); + + OpenApiSchema fullSchema = schemaRepository.Schemas[referenceSchema.Reference.Id]; + + if (JsonApiSchemaFacts.HasNullableDataProperty(relationshipSchemaType)) + { + fullSchema.Properties[JsonApiPropertyName.Data].Nullable = true; + } + + if (JsonApiSchemaFacts.IsRelationshipInResponseType(relationshipSchemaType)) + { + _linksVisibilitySchemaGenerator.UpdateSchemaForRelationship(relationshipSchemaType, fullSchema, schemaRepository); + } + + traceScope.TraceSucceeded(referenceSchema.Reference.Id); + return referenceSchema; + } + + private static void AssertHasNoProperties(OpenApiSchema fullSchema) + { + ConsistencyGuard.ThrowIf(fullSchema.Properties.Count > 0); + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/ResourceSchemaType.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/ResourceSchemaType.cs new file mode 100644 index 0000000000..148f0f4e2c --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/ResourceSchemaType.cs @@ -0,0 +1,52 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.AtomicOperations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Documents; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.Relationships; +using JsonApiDotNetCore.OpenApi.Swashbuckle.JsonApiObjects.ResourceObjects; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.SwaggerComponents; + +/// +/// Represents a generic component schema type, whose first type parameter implements . Examples: +/// , , +/// , . +/// +internal sealed class ResourceSchemaType +{ + public Type SchemaConstructedType { get; } + public Type SchemaOpenType { get; } + public ResourceType ResourceType { get; } + + private ResourceSchemaType(Type schemaConstructedType, Type schemaOpenType, ResourceType resourceType) + { + SchemaConstructedType = schemaConstructedType; + SchemaOpenType = schemaOpenType; + ResourceType = resourceType; + } + + public static ResourceSchemaType Create(Type schemaConstructedType, IResourceGraph resourceGraph) + { + ArgumentNullException.ThrowIfNull(schemaConstructedType); + ArgumentNullException.ThrowIfNull(resourceGraph); + + Type schemaOpenType = schemaConstructedType.GetGenericTypeDefinition(); + Type resourceClrType = schemaConstructedType.GenericTypeArguments[0]; + ResourceType resourceType = resourceGraph.GetResourceType(resourceClrType); + + return new ResourceSchemaType(schemaConstructedType, schemaOpenType, resourceType); + } + + public ResourceSchemaType ChangeResourceType(ResourceType resourceType) + { + ArgumentNullException.ThrowIfNull(resourceType); + + Type schemaConstructedType = SchemaOpenType.MakeGenericType(resourceType.ClrType); + return new ResourceSchemaType(schemaConstructedType, SchemaOpenType, resourceType); + } + + public override string ToString() + { + return $"{SchemaOpenType.Name} for {ResourceType.ClrType.Name}"; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/ServerDocumentFilter.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/ServerDocumentFilter.cs new file mode 100644 index 0000000000..723499d39c --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/ServerDocumentFilter.cs @@ -0,0 +1,32 @@ +using JetBrains.Annotations; +using Microsoft.AspNetCore.Http; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.SwaggerComponents; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +internal sealed class ServerDocumentFilter : IDocumentFilter +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + public ServerDocumentFilter(IHttpContextAccessor httpContextAccessor) + { + ArgumentNullException.ThrowIfNull(httpContextAccessor); + + _httpContextAccessor = httpContextAccessor; + } + + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) + { + if (swaggerDoc.Servers.Count == 0 && _httpContextAccessor.HttpContext != null) + { + HttpRequest httpRequest = _httpContextAccessor.HttpContext.Request; + + swaggerDoc.Servers.Add(new OpenApiServer + { + Url = $"{httpRequest.Scheme}://{httpRequest.Host.Value}" + }); + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/StringEnumOrderingFilter.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/StringEnumOrderingFilter.cs new file mode 100644 index 0000000000..dec10db97b --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/SwaggerComponents/StringEnumOrderingFilter.cs @@ -0,0 +1,54 @@ +using JetBrains.Annotations; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Interfaces; +using Microsoft.OpenApi.Models; +using Microsoft.OpenApi.Services; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle.SwaggerComponents; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +internal sealed class StringEnumOrderingFilter : IDocumentFilter +{ + internal const string RequiresSortKey = "x-requires-sort"; + + public void Apply(OpenApiDocument document, DocumentFilterContext context) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(context); + + var visitor = new OpenApiEnumVisitor(); + var walker = new OpenApiWalker(visitor); + walker.Walk(document); + } + + private sealed class OpenApiEnumVisitor : OpenApiVisitorBase + { + public override void Visit(OpenApiSchema schema) + { + if (HasSortAnnotation(schema)) + { + if (schema.Enum.Count > 1) + { + OrderEnumMembers(schema); + } + } + + schema.Extensions.Remove(RequiresSortKey); + } + + private static bool HasSortAnnotation(OpenApiSchema schema) + { + // Order our own enums, but don't touch enums from user-defined resource attributes. + return schema.Extensions.TryGetValue(RequiresSortKey, out IOpenApiExtension? extension) && extension is OpenApiBoolean { Value: true }; + } + + private static void OrderEnumMembers(OpenApiSchema schema) + { + List ordered = schema.Enum.OfType().OrderBy(openApiString => openApiString.Value).Cast().ToList(); + ConsistencyGuard.ThrowIf(ordered.Count != schema.Enum.Count); + + schema.Enum = ordered; + } + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/TypeExtensions.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/TypeExtensions.cs new file mode 100644 index 0000000000..ebfb000577 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/TypeExtensions.cs @@ -0,0 +1,11 @@ +namespace JsonApiDotNetCore.OpenApi.Swashbuckle; + +internal static class TypeExtensions +{ + public static Type ConstructedToOpenType(this Type type) + { + ArgumentNullException.ThrowIfNull(type); + + return type.IsConstructedGenericType ? type.GetGenericTypeDefinition() : type; + } +} diff --git a/src/JsonApiDotNetCore.OpenApi.Swashbuckle/UnusedComponentSchemaCleaner.cs b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/UnusedComponentSchemaCleaner.cs new file mode 100644 index 0000000000..4e2addabb3 --- /dev/null +++ b/src/JsonApiDotNetCore.OpenApi.Swashbuckle/UnusedComponentSchemaCleaner.cs @@ -0,0 +1,185 @@ +using System.Diagnostics; +using JetBrains.Annotations; +using JsonApiDotNetCore.OpenApi.Swashbuckle.SchemaGenerators; +using Microsoft.OpenApi.Interfaces; +using Microsoft.OpenApi.Models; +using Microsoft.OpenApi.Services; +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace JsonApiDotNetCore.OpenApi.Swashbuckle; + +/// +/// Removes unreferenced component schemas from the OpenAPI document. +/// +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +internal sealed class UnusedComponentSchemaCleaner : IDocumentFilter +{ + private const string ComponentSchemaPrefix = "#/components/schemas/"; + + public void Apply(OpenApiDocument document, DocumentFilterContext context) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(context); + + document.Components.Schemas.Remove(GenerationCacheSchemaGenerator.SchemaId); + + HashSet unusedSchemaIds = GetUnusedSchemaIds(document); + AssertNoUnknownSchemasFound(unusedSchemaIds); + + RemoveUnusedComponentSchemas(document, unusedSchemaIds); + } + + private static HashSet GetUnusedSchemaIds(OpenApiDocument document) + { + HashSet reachableSchemaIds = ReachableRootsCollector.Instance.CollectReachableSchemaIds(document); + + ComponentSchemaUsageCollector collector = new(document); + return collector.CollectUnusedSchemaIds(reachableSchemaIds); + } + + [Conditional("DEBUG")] + private static void AssertNoUnknownSchemasFound(HashSet unusedSchemaIds) + { + if (unusedSchemaIds.Count > 0) + { + throw new InvalidOperationException($"Detected unused component schemas: {string.Join(", ", unusedSchemaIds)}"); + } + } + + private static void RemoveUnusedComponentSchemas(OpenApiDocument document, HashSet unusedSchemaIds) + { + foreach (string schemaId in unusedSchemaIds) + { + document.Components.Schemas.Remove(schemaId); + } + } + + private sealed class ReachableRootsCollector + { + public static ReachableRootsCollector Instance { get; } = new(); + + private ReachableRootsCollector() + { + } + + public HashSet CollectReachableSchemaIds(OpenApiDocument document) + { + ArgumentNullException.ThrowIfNull(document); + + var visitor = new ComponentSchemaReferenceVisitor(); + + var walker = new OpenApiWalker(visitor); + walker.Walk(document); + + return visitor.ReachableSchemaIds; + } + + private sealed class ComponentSchemaReferenceVisitor : OpenApiVisitorBase + { + public HashSet ReachableSchemaIds { get; } = []; + + public override void Visit(IOpenApiReferenceable referenceable) + { + if (!PathString.StartsWith(ComponentSchemaPrefix, StringComparison.Ordinal)) + { + if (referenceable is OpenApiSchema schema) + { + ReachableSchemaIds.Add(schema.Reference.Id); + } + } + } + } + } + + private sealed class ComponentSchemaUsageCollector + { + private readonly IDictionary _componentSchemas; + private readonly HashSet _schemaIdsInUse = []; + + public ComponentSchemaUsageCollector(OpenApiDocument document) + { + ArgumentNullException.ThrowIfNull(document); + + _componentSchemas = document.Components.Schemas; + } + + public HashSet CollectUnusedSchemaIds(ICollection reachableSchemaIds) + { + _schemaIdsInUse.Clear(); + + foreach (string schemaId in reachableSchemaIds) + { + WalkSchemaId(schemaId); + } + + HashSet unusedSchemaIds = _componentSchemas.Keys.ToHashSet(); + unusedSchemaIds.ExceptWith(_schemaIdsInUse); + return unusedSchemaIds; + } + + private void WalkSchemaId(string schemaId) + { + if (_schemaIdsInUse.Add(schemaId)) + { + if (_componentSchemas.TryGetValue(schemaId, out OpenApiSchema? schema)) + { + WalkSchema(schema); + } + } + } + + private void WalkSchema(OpenApiSchema? schema) + { + if (schema != null) + { + VisitSchema(schema); + + WalkSchema(schema.Items); + WalkSchema(schema.Not); + + foreach (OpenApiSchema? subSchema in schema.AllOf) + { + WalkSchema(subSchema); + } + + foreach (OpenApiSchema? subSchema in schema.AnyOf) + { + WalkSchema(subSchema); + } + + foreach (OpenApiSchema? subSchema in schema.OneOf) + { + WalkSchema(subSchema); + } + + foreach (OpenApiSchema? subSchema in schema.Properties.Values) + { + WalkSchema(subSchema); + } + + // ReSharper disable once TailRecursiveCall + WalkSchema(schema.AdditionalProperties); + } + } + + private void VisitSchema(OpenApiSchema schema) + { + if (schema.Reference is { Type: ReferenceType.Schema, IsExternal: false }) + { + WalkSchemaId(schema.Reference.Id); + } + + if (schema.Discriminator != null) + { + foreach (string mappingValue in schema.Discriminator.Mapping.Values) + { + if (mappingValue.StartsWith(ComponentSchemaPrefix, StringComparison.Ordinal)) + { + string schemaId = mappingValue[ComponentSchemaPrefix.Length..]; + WalkSchemaId(schemaId); + } + } + } + } + } +} diff --git a/src/JsonApiDotNetCore.SourceGenerators/ControllerSourceGenerator.cs b/src/JsonApiDotNetCore.SourceGenerators/ControllerSourceGenerator.cs index 6728fd537c..1b47821d22 100644 --- a/src/JsonApiDotNetCore.SourceGenerators/ControllerSourceGenerator.cs +++ b/src/JsonApiDotNetCore.SourceGenerators/ControllerSourceGenerator.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; using System.Collections.Immutable; -using System.Linq; using System.Text; using Humanizer; using Microsoft.CodeAnalysis; @@ -9,162 +6,163 @@ using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Text; -#pragma warning disable RS2008 // Enable analyzer release tracking +namespace JsonApiDotNetCore.SourceGenerators; +// To debug in Visual Studio (requires v17.2 or higher): +// - Set JsonApiDotNetCore.SourceGenerators as startup project +// - Add a breakpoint at the start of the Initialize or Execute method +// - Optional: change targetProject in Properties\launchSettings.json +// - Press F5 -namespace JsonApiDotNetCore.SourceGenerators +[Generator(LanguageNames.CSharp)] +public sealed class ControllerSourceGenerator : ISourceGenerator { - [Generator(LanguageNames.CSharp)] - public sealed class ControllerSourceGenerator : ISourceGenerator + private const string Category = "JsonApiDotNetCore"; + + private static readonly DiagnosticDescriptor MissingInterfaceWarning = new("JADNC001", "Resource type does not implement IIdentifiable", + "Type '{0}' must implement IIdentifiable when using ResourceAttribute to auto-generate ASP.NET controllers", Category, DiagnosticSeverity.Warning, + true); + + private static readonly DiagnosticDescriptor MissingIndentInTableError = new("JADNC900", "Internal error: Insufficient entries in IndentTable", + "Internal error: Missing entry in IndentTable for depth {0}", Category, DiagnosticSeverity.Warning, true); + + // PERF: Heap-allocate the delegate only once, instead of per compilation. + private static readonly SyntaxReceiverCreator CreateSyntaxReceiver = static () => new TypeWithAttributeSyntaxReceiver(); + + public void Initialize(GeneratorInitializationContext context) { - private const string Category = "JsonApiDotNetCore"; + context.RegisterForSyntaxNotifications(CreateSyntaxReceiver); + } - private static readonly DiagnosticDescriptor MissingInterfaceWarning = new DiagnosticDescriptor("JADNC001", - "Resource type does not implement IIdentifiable", - "Type '{0}' must implement IIdentifiable when using ResourceAttribute to auto-generate ASP.NET controllers", Category, - DiagnosticSeverity.Warning, true); + public void Execute(GeneratorExecutionContext context) + { + var receiver = (TypeWithAttributeSyntaxReceiver?)context.SyntaxReceiver; - private static readonly DiagnosticDescriptor MissingIndentInTableError = new DiagnosticDescriptor("JADNC900", - "Internal error: Insufficient entries in IndentTable", "Internal error: Missing entry in IndentTable for depth {0}", Category, - DiagnosticSeverity.Warning, true); + if (receiver == null) + { + return; + } - // PERF: Heap-allocate the delegate only once, instead of per compilation. - private static readonly SyntaxReceiverCreator CreateSyntaxReceiver = () => new TypeWithAttributeSyntaxReceiver(); + INamedTypeSymbol? resourceAttributeType = context.Compilation.GetTypeByMetadataName("JsonApiDotNetCore.Resources.Annotations.ResourceAttribute"); + INamedTypeSymbol? identifiableOpenInterface = context.Compilation.GetTypeByMetadataName("JsonApiDotNetCore.Resources.IIdentifiable`1"); + INamedTypeSymbol? loggerFactoryInterface = context.Compilation.GetTypeByMetadataName("Microsoft.Extensions.Logging.ILoggerFactory"); - public void Initialize(GeneratorInitializationContext context) + if (resourceAttributeType == null || identifiableOpenInterface == null || loggerFactoryInterface == null) { - context.RegisterForSyntaxNotifications(CreateSyntaxReceiver); + return; } - public void Execute(GeneratorExecutionContext context) + var controllerNamesInUse = new Dictionary(StringComparer.OrdinalIgnoreCase); + var writer = new SourceCodeWriter(context, MissingIndentInTableError); + + foreach (TypeDeclarationSyntax? typeDeclarationSyntax in receiver.TypeDeclarations) { - var receiver = (TypeWithAttributeSyntaxReceiver)context.SyntaxReceiver; + // PERF: Note that our code runs on every keystroke in the IDE, which makes it critical to provide near-realtime performance. + // This means keeping an eye on memory allocations and bailing out early when compilations are cancelled while the user is still typing. + context.CancellationToken.ThrowIfCancellationRequested(); - if (receiver == null) + SemanticModel semanticModel = context.Compilation.GetSemanticModel(typeDeclarationSyntax.SyntaxTree); + INamedTypeSymbol? resourceType = semanticModel.GetDeclaredSymbol(typeDeclarationSyntax, context.CancellationToken); + + if (resourceType == null) { - return; + continue; } - INamedTypeSymbol resourceAttributeType = context.Compilation.GetTypeByMetadataName("JsonApiDotNetCore.Resources.Annotations.ResourceAttribute"); - INamedTypeSymbol identifiableOpenInterface = context.Compilation.GetTypeByMetadataName("JsonApiDotNetCore.Resources.IIdentifiable`1"); - INamedTypeSymbol loggerFactoryInterface = context.Compilation.GetTypeByMetadataName("Microsoft.Extensions.Logging.ILoggerFactory"); + AttributeData? resourceAttributeData = FirstOrDefault(resourceType.GetAttributes(), resourceAttributeType, + static (data, type) => SymbolEqualityComparer.Default.Equals(data.AttributeClass, type)); - if (resourceAttributeType == null || identifiableOpenInterface == null || loggerFactoryInterface == null) + if (resourceAttributeData == null) { - return; + continue; } - var controllerNamesInUse = new Dictionary(StringComparer.OrdinalIgnoreCase); - var writer = new SourceCodeWriter(context, MissingIndentInTableError); + TypedConstant endpointsArgument = + resourceAttributeData.NamedArguments.FirstOrDefault(static pair => pair.Key == "GenerateControllerEndpoints").Value; - foreach (TypeDeclarationSyntax typeDeclarationSyntax in receiver.TypeDeclarations) + if (endpointsArgument.Value != null && (JsonApiEndpointsCopy)endpointsArgument.Value == JsonApiEndpointsCopy.None) { - // PERF: Note that our code runs on every keystroke in the IDE, which makes it critical to provide near-realtime performance. - // This means keeping an eye on memory allocations and bailing out early when compilations are cancelled while the user is still typing. - context.CancellationToken.ThrowIfCancellationRequested(); - - SemanticModel semanticModel = context.Compilation.GetSemanticModel(typeDeclarationSyntax.SyntaxTree); - INamedTypeSymbol resourceType = semanticModel.GetDeclaredSymbol(typeDeclarationSyntax, context.CancellationToken); - - if (resourceType == null) - { - continue; - } - - AttributeData resourceAttributeData = FirstOrDefault(resourceType.GetAttributes(), resourceAttributeType, - (data, type) => SymbolEqualityComparer.Default.Equals(data.AttributeClass, type)); - - if (resourceAttributeData == null) - { - continue; - } - - TypedConstant endpointsArgument = resourceAttributeData.NamedArguments.FirstOrDefault(pair => pair.Key == "GenerateControllerEndpoints").Value; - - if (endpointsArgument.Value != null && (JsonApiEndpointsCopy)endpointsArgument.Value == JsonApiEndpointsCopy.None) - { - continue; - } - - TypedConstant controllerNamespaceArgument = - resourceAttributeData.NamedArguments.FirstOrDefault(pair => pair.Key == "ControllerNamespace").Value; - - string controllerNamespace = GetControllerNamespace(controllerNamespaceArgument, resourceType); - - INamedTypeSymbol identifiableClosedInterface = FirstOrDefault(resourceType.AllInterfaces, identifiableOpenInterface, - (@interface, openInterface) => @interface.IsGenericType && - SymbolEqualityComparer.Default.Equals(@interface.ConstructedFrom, openInterface)); + continue; + } - if (identifiableClosedInterface == null) - { - var diagnostic = Diagnostic.Create(MissingInterfaceWarning, typeDeclarationSyntax.GetLocation(), resourceType.Name); - context.ReportDiagnostic(diagnostic); - continue; - } + TypedConstant controllerNamespaceArgument = + resourceAttributeData.NamedArguments.FirstOrDefault(static pair => pair.Key == "ControllerNamespace").Value; - ITypeSymbol idType = identifiableClosedInterface.TypeArguments[0]; - string controllerName = $"{resourceType.Name.Pluralize()}Controller"; - JsonApiEndpointsCopy endpointsToGenerate = (JsonApiEndpointsCopy?)(int?)endpointsArgument.Value ?? JsonApiEndpointsCopy.All; + string? controllerNamespace = GetControllerNamespace(controllerNamespaceArgument, resourceType); - string sourceCode = writer.Write(resourceType, idType, endpointsToGenerate, controllerNamespace, controllerName, loggerFactoryInterface); - SourceText sourceText = SourceText.From(sourceCode, Encoding.UTF8); + INamedTypeSymbol? identifiableClosedInterface = FirstOrDefault(resourceType.AllInterfaces, identifiableOpenInterface, + static (@interface, openInterface) => + @interface.IsGenericType && SymbolEqualityComparer.Default.Equals(@interface.ConstructedFrom, openInterface)); - string fileName = GetUniqueFileName(controllerName, controllerNamesInUse); - context.AddSource(fileName, sourceText); + if (identifiableClosedInterface == null) + { + var diagnostic = Diagnostic.Create(MissingInterfaceWarning, typeDeclarationSyntax.GetLocation(), resourceType.Name); + context.ReportDiagnostic(diagnostic); + continue; } - } - private static TElement FirstOrDefault(ImmutableArray source, TContext context, Func predicate) - { - // PERF: Using this method enables to avoid allocating a closure in the passed lambda expression. - // See https://www.jetbrains.com/help/resharper/2021.2/Fixing_Issues_Found_by_DPA.html#closures-in-lambda-expressions. + ITypeSymbol idType = identifiableClosedInterface.TypeArguments[0]; + string controllerName = $"{resourceType.Name.Pluralize()}Controller"; + JsonApiEndpointsCopy endpointsToGenerate = (JsonApiEndpointsCopy?)(int?)endpointsArgument.Value ?? JsonApiEndpointsCopy.All; - foreach (TElement element in source) - { - if (predicate(element, context)) - { - return element; - } - } + string sourceCode = writer.Write(resourceType, idType, endpointsToGenerate, controllerNamespace, controllerName, loggerFactoryInterface); + SourceText sourceText = SourceText.From(sourceCode, Encoding.UTF8); - return default; + string fileName = GetUniqueFileName(controllerName, controllerNamesInUse); + context.AddSource(fileName, sourceText); } + } - private static string GetControllerNamespace(TypedConstant controllerNamespaceArgument, INamedTypeSymbol resourceType) + private static TElement? FirstOrDefault(ImmutableArray source, TContext context, Func predicate) + { + // PERF: Using this method enables to avoid allocating a closure in the passed lambda expression. + // See https://www.jetbrains.com/help/resharper/2021.2/Fixing_Issues_Found_by_DPA.html#closures-in-lambda-expressions. + + foreach (TElement element in source) { - if (!controllerNamespaceArgument.IsNull) + if (predicate(element, context)) { - return (string)controllerNamespaceArgument.Value; + return element; } + } - if (resourceType.ContainingNamespace.IsGlobalNamespace) - { - return null; - } + return default; + } - if (resourceType.ContainingNamespace.ContainingNamespace.IsGlobalNamespace) - { - return "Controllers"; - } + private static string? GetControllerNamespace(TypedConstant controllerNamespaceArgument, INamedTypeSymbol resourceType) + { + if (!controllerNamespaceArgument.IsNull) + { + return (string?)controllerNamespaceArgument.Value; + } - return $"{resourceType.ContainingNamespace.ContainingNamespace}.Controllers"; + if (resourceType.ContainingNamespace.IsGlobalNamespace) + { + return null; } - private static string GetUniqueFileName(string controllerName, IDictionary controllerNamesInUse) + if (resourceType.ContainingNamespace.ContainingNamespace.IsGlobalNamespace) { - // We emit unique file names to prevent a failure in the source generator, but also because our test suite - // may contain two resources with the same class name in different namespaces. That works, as long as only - // one of its controllers gets registered. + return "Controllers"; + } - if (controllerNamesInUse.TryGetValue(controllerName, out int lastIndex)) - { - lastIndex++; - controllerNamesInUse[controllerName] = lastIndex; + return $"{resourceType.ContainingNamespace.ContainingNamespace}.Controllers"; + } - return $"{controllerName}{lastIndex}.g.cs"; - } + private static string GetUniqueFileName(string controllerName, IDictionary controllerNamesInUse) + { + // We emit unique file names to prevent a failure in the source generator, but also because our test suite + // may contain two resources with the same class name in different namespaces. That works, as long as only + // one of its controllers gets registered. - controllerNamesInUse[controllerName] = 1; - return $"{controllerName}.g.cs"; + if (controllerNamesInUse.TryGetValue(controllerName, out int lastIndex)) + { + lastIndex++; + controllerNamesInUse[controllerName] = lastIndex; + + return $"{controllerName}{lastIndex}.g.cs"; } + + controllerNamesInUse[controllerName] = 1; + return $"{controllerName}.g.cs"; } } diff --git a/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj b/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj index 9f7c0b85dd..db6f039bd1 100644 --- a/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj +++ b/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj @@ -5,29 +5,27 @@ true false $(NoWarn);NU5128 - disable - disable true + + - $(JsonApiDotNetCoreVersionPrefix) jsonapidotnetcore;jsonapi;json:api;dotnet;asp.net;rest;web-api - Source generators for JsonApiDotNetCore, a framework for building JSON:API compliant REST APIs using ASP.NET and Entity Framework Core. + Source generators for JsonApiDotNetCore, which is a framework for building JSON:API compliant REST APIs using ASP.NET Core and Entity Framework Core. json-api-dotnet https://www.jsonapi.net/ MIT false See https://github.com/json-api-dotnet/JsonApiDotNetCore/releases. - logo.png + package-icon.png + PackageReadme.md https://github.com/json-api-dotnet/JsonApiDotNetCore - - True - - + + @@ -47,7 +45,7 @@ - - + + 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.SourceGenerators/JsonApiEndpointsCopy.cs b/src/JsonApiDotNetCore.SourceGenerators/JsonApiEndpointsCopy.cs index 14134adcfd..911be3f359 100644 --- a/src/JsonApiDotNetCore.SourceGenerators/JsonApiEndpointsCopy.cs +++ b/src/JsonApiDotNetCore.SourceGenerators/JsonApiEndpointsCopy.cs @@ -1,26 +1,23 @@ -using System; +namespace JsonApiDotNetCore.SourceGenerators; -namespace JsonApiDotNetCore.SourceGenerators +// IMPORTANT: A copy of this type exists in the JsonApiDotNetCore project. Keep these in sync when making changes. +[Flags] +public enum JsonApiEndpointsCopy { - // IMPORTANT: A copy of this type exists in the JsonApiDotNetCore project. Keep these in sync when making changes. - [Flags] - public enum JsonApiEndpointsCopy - { - None = 0, - GetCollection = 1, - GetSingle = 1 << 1, - GetSecondary = 1 << 2, - GetRelationship = 1 << 3, - Post = 1 << 4, - PostRelationship = 1 << 5, - Patch = 1 << 6, - PatchRelationship = 1 << 7, - Delete = 1 << 8, - DeleteRelationship = 1 << 9, + None = 0, + GetCollection = 1, + GetSingle = 1 << 1, + GetSecondary = 1 << 2, + GetRelationship = 1 << 3, + Post = 1 << 4, + PostRelationship = 1 << 5, + Patch = 1 << 6, + PatchRelationship = 1 << 7, + Delete = 1 << 8, + DeleteRelationship = 1 << 9, - Query = GetCollection | GetSingle | GetSecondary | GetRelationship, - Command = Post | PostRelationship | Patch | PatchRelationship | Delete | DeleteRelationship, + Query = GetCollection | GetSingle | GetSecondary | GetRelationship, + Command = Post | PostRelationship | Patch | PatchRelationship | Delete | DeleteRelationship, - All = Query | Command - } + All = Query | Command } diff --git a/src/JsonApiDotNetCore.SourceGenerators/Properties/launchSettings.json b/src/JsonApiDotNetCore.SourceGenerators/Properties/launchSettings.json index 2679b059a9..03635841ec 100644 --- a/src/JsonApiDotNetCore.SourceGenerators/Properties/launchSettings.json +++ b/src/JsonApiDotNetCore.SourceGenerators/Properties/launchSettings.json @@ -2,7 +2,7 @@ "profiles": { "JsonApiDotNetCore.SourceGenerators": { "commandName": "DebugRoslynComponent", - "targetProject": "..\\..\\test\\SourceGeneratorDebugger\\SourceGeneratorDebugger.csproj" + "targetProject": "..\\Examples\\JsonApiDotNetCoreExample\\JsonApiDotNetCoreExample.csproj" } } } diff --git a/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs b/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs index e03e3cbad2..3df1092c4b 100644 --- a/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs +++ b/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs @@ -1,266 +1,254 @@ -using System.Collections.Generic; using System.Text; using Microsoft.CodeAnalysis; -namespace JsonApiDotNetCore.SourceGenerators +namespace JsonApiDotNetCore.SourceGenerators; + +/// +/// Writes the source code for an ASP.NET controller for a JSON:API resource. +/// +internal sealed class SourceCodeWriter(GeneratorExecutionContext context, DiagnosticDescriptor missingIndentInTableErrorDescriptor) { - /// - /// Writes the source code for an ASP.NET controller for a JSON:API resource. - /// - internal sealed class SourceCodeWriter + private const int SpacesPerIndent = 4; + + private static readonly Dictionary IndentTable = new() { - private const int SpacesPerIndent = 4; + [0] = string.Empty, + [1] = new string(' ', 1 * SpacesPerIndent), + [2] = new string(' ', 2 * SpacesPerIndent), + [3] = new string(' ', 3 * SpacesPerIndent) + }; - private static readonly IDictionary IndentTable = new Dictionary - { - [0] = string.Empty, - [1] = new string(' ', 1 * SpacesPerIndent), - [2] = new string(' ', 2 * SpacesPerIndent), - [3] = new string(' ', 3 * SpacesPerIndent) - }; + private static readonly Dictionary AggregateEndpointToServiceNameMap = new() + { + [JsonApiEndpointsCopy.All] = ("IResourceService", "resourceService"), + [JsonApiEndpointsCopy.Query] = ("IResourceQueryService", "queryService"), + [JsonApiEndpointsCopy.Command] = ("IResourceCommandService", "commandService") + }; - private static readonly IDictionary AggregateEndpointToServiceNameMap = - new Dictionary - { - [JsonApiEndpointsCopy.All] = ("IResourceService", "resourceService"), - [JsonApiEndpointsCopy.Query] = ("IResourceQueryService", "queryService"), - [JsonApiEndpointsCopy.Command] = ("IResourceCommandService", "commandService") - }; + private static readonly Dictionary EndpointToServiceNameMap = new() + { + [JsonApiEndpointsCopy.GetCollection] = ("IGetAllService", "getAll"), + [JsonApiEndpointsCopy.GetSingle] = ("IGetByIdService", "getById"), + [JsonApiEndpointsCopy.GetSecondary] = ("IGetSecondaryService", "getSecondary"), + [JsonApiEndpointsCopy.GetRelationship] = ("IGetRelationshipService", "getRelationship"), + [JsonApiEndpointsCopy.Post] = ("ICreateService", "create"), + [JsonApiEndpointsCopy.PostRelationship] = ("IAddToRelationshipService", "addToRelationship"), + [JsonApiEndpointsCopy.Patch] = ("IUpdateService", "update"), + [JsonApiEndpointsCopy.PatchRelationship] = ("ISetRelationshipService", "setRelationship"), + [JsonApiEndpointsCopy.Delete] = ("IDeleteService", "delete"), + [JsonApiEndpointsCopy.DeleteRelationship] = ("IRemoveFromRelationshipService", "removeFromRelationship") + }; + + private readonly GeneratorExecutionContext _context = context; + private readonly DiagnosticDescriptor _missingIndentInTableErrorDescriptor = missingIndentInTableErrorDescriptor; + + private readonly StringBuilder _sourceBuilder = new(); + private int _depth; + + public string Write(INamedTypeSymbol resourceType, ITypeSymbol idType, JsonApiEndpointsCopy endpointsToGenerate, string? controllerNamespace, + string controllerName, INamedTypeSymbol loggerFactoryInterface) + { + _sourceBuilder.Clear(); + _depth = 0; - private static readonly IDictionary EndpointToServiceNameMap = - new Dictionary - { - [JsonApiEndpointsCopy.GetCollection] = ("IGetAllService", "getAll"), - [JsonApiEndpointsCopy.GetSingle] = ("IGetByIdService", "getById"), - [JsonApiEndpointsCopy.GetSecondary] = ("IGetSecondaryService", "getSecondary"), - [JsonApiEndpointsCopy.GetRelationship] = ("IGetRelationshipService", "getRelationship"), - [JsonApiEndpointsCopy.Post] = ("ICreateService", "create"), - [JsonApiEndpointsCopy.PostRelationship] = ("IAddToRelationshipService", "addToRelationship"), - [JsonApiEndpointsCopy.Patch] = ("IUpdateService", "update"), - [JsonApiEndpointsCopy.PatchRelationship] = ("ISetRelationshipService", "setRelationship"), - [JsonApiEndpointsCopy.Delete] = ("IDeleteService", "delete"), - [JsonApiEndpointsCopy.DeleteRelationship] = ("IRemoveFromRelationshipService", "removeFromRelationship") - }; - - private readonly GeneratorExecutionContext _context; - private readonly DiagnosticDescriptor _missingIndentInTableErrorDescriptor; - - private readonly StringBuilder _sourceBuilder = new StringBuilder(); - private int _depth; - - public SourceCodeWriter(GeneratorExecutionContext context, DiagnosticDescriptor missingIndentInTableErrorDescriptor) + WriteAutoGeneratedComment(); + + if (idType is { IsReferenceType: true, NullableAnnotation: NullableAnnotation.Annotated }) { - _context = context; - _missingIndentInTableErrorDescriptor = missingIndentInTableErrorDescriptor; + WriteNullableEnable(); } - public string Write(INamedTypeSymbol resourceType, ITypeSymbol idType, JsonApiEndpointsCopy endpointsToGenerate, string controllerNamespace, - string controllerName, INamedTypeSymbol loggerFactoryInterface) + WriteNamespaceImports(loggerFactoryInterface, resourceType, controllerNamespace); + + if (controllerNamespace != null) { - _sourceBuilder.Clear(); - _depth = 0; + WriteNamespaceDeclaration(controllerNamespace); + } - if (idType.IsReferenceType && idType.NullableAnnotation == NullableAnnotation.Annotated) - { - WriteNullableEnable(); - } + WriteOpenClassDeclaration(controllerName, endpointsToGenerate, resourceType, idType); + _depth++; - WriteNamespaceImports(loggerFactoryInterface, resourceType); + WriteConstructor(controllerName, loggerFactoryInterface, endpointsToGenerate, resourceType, idType); - if (controllerNamespace != null) - { - WriteNamespaceDeclaration(controllerNamespace); - } + _depth--; + WriteCloseCurly(); + + return _sourceBuilder.ToString(); + } - WriteOpenClassDeclaration(controllerName, endpointsToGenerate, resourceType, idType); - _depth++; + private void WriteAutoGeneratedComment() + { + _sourceBuilder.AppendLine("// "); + _sourceBuilder.AppendLine(); + } - WriteConstructor(controllerName, loggerFactoryInterface, endpointsToGenerate, resourceType, idType); + private void WriteNullableEnable() + { + _sourceBuilder.AppendLine("#nullable enable"); + _sourceBuilder.AppendLine(); + } - _depth--; - WriteCloseCurly(); + private void WriteNamespaceImports(INamedTypeSymbol loggerFactoryInterface, INamedTypeSymbol resourceType, string? controllerNamespace) + { + _sourceBuilder.AppendLine($"using {loggerFactoryInterface.ContainingNamespace};"); - return _sourceBuilder.ToString(); - } + _sourceBuilder.AppendLine("using JsonApiDotNetCore.Configuration;"); + _sourceBuilder.AppendLine("using JsonApiDotNetCore.Controllers;"); + _sourceBuilder.AppendLine("using JsonApiDotNetCore.Services;"); - private void WriteNullableEnable() + if (!resourceType.ContainingNamespace.IsGlobalNamespace && resourceType.ContainingNamespace.ToString() != controllerNamespace) { - _sourceBuilder.AppendLine("#nullable enable"); - _sourceBuilder.AppendLine(); + _sourceBuilder.AppendLine($"using {resourceType.ContainingNamespace};"); } - private void WriteNamespaceImports(INamedTypeSymbol loggerFactoryInterface, INamedTypeSymbol resourceType) - { - _sourceBuilder.AppendLine($@"using {loggerFactoryInterface.ContainingNamespace};"); + _sourceBuilder.AppendLine(); + } - _sourceBuilder.AppendLine("using JsonApiDotNetCore.Configuration;"); - _sourceBuilder.AppendLine("using JsonApiDotNetCore.Controllers;"); - _sourceBuilder.AppendLine("using JsonApiDotNetCore.Services;"); + private void WriteNamespaceDeclaration(string controllerNamespace) + { + _sourceBuilder.AppendLine($"namespace {controllerNamespace};"); + _sourceBuilder.AppendLine(); + } - if (!resourceType.ContainingNamespace.IsGlobalNamespace) - { - _sourceBuilder.AppendLine($"using {resourceType.ContainingNamespace};"); - } + private void WriteOpenClassDeclaration(string controllerName, JsonApiEndpointsCopy endpointsToGenerate, INamedTypeSymbol resourceType, ITypeSymbol idType) + { + string baseClassName = GetControllerBaseClassName(endpointsToGenerate); - _sourceBuilder.AppendLine(); - } + WriteIndent(); + _sourceBuilder.AppendLine($"public sealed partial class {controllerName} : {baseClassName}<{resourceType.Name}, {idType}>"); - private void WriteNamespaceDeclaration(string controllerNamespace) - { - _sourceBuilder.AppendLine($"namespace {controllerNamespace};"); - _sourceBuilder.AppendLine(); - } + WriteOpenCurly(); + } - private void WriteOpenClassDeclaration(string controllerName, JsonApiEndpointsCopy endpointsToGenerate, INamedTypeSymbol resourceType, - ITypeSymbol idType) + private static string GetControllerBaseClassName(JsonApiEndpointsCopy endpointsToGenerate) + { + return endpointsToGenerate switch { - string baseClassName = GetControllerBaseClassName(endpointsToGenerate); + JsonApiEndpointsCopy.Query => "JsonApiQueryController", + JsonApiEndpointsCopy.Command => "JsonApiCommandController", + _ => "JsonApiController" + }; + } + + private void WriteConstructor(string controllerName, INamedTypeSymbol loggerFactoryInterface, JsonApiEndpointsCopy endpointsToGenerate, + INamedTypeSymbol resourceType, ITypeSymbol idType) + { + string loggerName = loggerFactoryInterface.Name; - WriteIndent(); - _sourceBuilder.AppendLine($@"public sealed partial class {controllerName} : {baseClassName}<{resourceType.Name}, {idType}>"); + WriteIndent(); + _sourceBuilder.AppendLine($"public {controllerName}(IJsonApiOptions options, IResourceGraph resourceGraph, {loggerName} loggerFactory,"); - WriteOpenCurly(); - } + _depth++; - private static string GetControllerBaseClassName(JsonApiEndpointsCopy endpointsToGenerate) + if (AggregateEndpointToServiceNameMap.TryGetValue(endpointsToGenerate, out (string ServiceName, string ParameterName) value)) { - switch (endpointsToGenerate) - { - case JsonApiEndpointsCopy.Query: - { - return "JsonApiQueryController"; - } - case JsonApiEndpointsCopy.Command: - { - return "JsonApiCommandController"; - } - default: - { - return "JsonApiController"; - } - } + WriteParameterListForShortConstructor(value.ServiceName, value.ParameterName, resourceType, idType); } - - private void WriteConstructor(string controllerName, INamedTypeSymbol loggerFactoryInterface, JsonApiEndpointsCopy endpointsToGenerate, - INamedTypeSymbol resourceType, ITypeSymbol idType) + else { - string loggerName = loggerFactoryInterface.Name; - - WriteIndent(); - _sourceBuilder.AppendLine($"public {controllerName}(IJsonApiOptions options, IResourceGraph resourceGraph, {loggerName} loggerFactory,"); - - _depth++; + WriteParameterListForLongConstructor(endpointsToGenerate, resourceType, idType); + } - if (AggregateEndpointToServiceNameMap.TryGetValue(endpointsToGenerate, out (string ServiceName, string ParameterName) value)) - { - WriteParameterListForShortConstructor(value.ServiceName, value.ParameterName, resourceType, idType); - } - else - { - WriteParameterListForLongConstructor(endpointsToGenerate, resourceType, idType); - } + _depth--; - _depth--; + WriteOpenCurly(); + WriteCloseCurly(); + } - WriteOpenCurly(); - WriteCloseCurly(); - } + private void WriteParameterListForShortConstructor(string serviceName, string parameterName, INamedTypeSymbol resourceType, ITypeSymbol idType) + { + WriteIndent(); + _sourceBuilder.AppendLine($"{serviceName}<{resourceType.Name}, {idType}> {parameterName})"); - private void WriteParameterListForShortConstructor(string serviceName, string parameterName, INamedTypeSymbol resourceType, ITypeSymbol idType) - { - WriteIndent(); - _sourceBuilder.AppendLine($"{serviceName}<{resourceType.Name}, {idType}> {parameterName})"); + WriteIndent(); + _sourceBuilder.AppendLine($": base(options, resourceGraph, loggerFactory, {parameterName})"); + } - WriteIndent(); - _sourceBuilder.AppendLine($": base(options, resourceGraph, loggerFactory, {parameterName})"); - } + private void WriteParameterListForLongConstructor(JsonApiEndpointsCopy endpointsToGenerate, INamedTypeSymbol resourceType, ITypeSymbol idType) + { + bool isFirstEntry = true; - private void WriteParameterListForLongConstructor(JsonApiEndpointsCopy endpointsToGenerate, INamedTypeSymbol resourceType, ITypeSymbol idType) + foreach (KeyValuePair entry in EndpointToServiceNameMap) { - bool isFirstEntry = true; - - foreach (KeyValuePair entry in EndpointToServiceNameMap) + if ((endpointsToGenerate & entry.Key) == entry.Key) { - if ((endpointsToGenerate & entry.Key) == entry.Key) + if (isFirstEntry) + { + isFirstEntry = false; + } + else { - if (isFirstEntry) - { - isFirstEntry = false; - } - else - { - _sourceBuilder.AppendLine(Tokens.Comma); - } - - WriteIndent(); - _sourceBuilder.Append($"{entry.Value.ServiceName}<{resourceType.Name}, {idType}> {entry.Value.ParameterName}"); + _sourceBuilder.AppendLine(Tokens.Comma); } + + WriteIndent(); + _sourceBuilder.Append($"{entry.Value.ServiceName}<{resourceType.Name}, {idType}> {entry.Value.ParameterName}"); } + } - _sourceBuilder.AppendLine(Tokens.CloseParen); + _sourceBuilder.AppendLine(Tokens.CloseParen); - WriteIndent(); - _sourceBuilder.AppendLine(": base(options, resourceGraph, loggerFactory,"); + WriteIndent(); + _sourceBuilder.AppendLine(": base(options, resourceGraph, loggerFactory,"); - isFirstEntry = true; - _depth++; + isFirstEntry = true; + _depth++; - foreach (KeyValuePair entry in EndpointToServiceNameMap) + foreach (KeyValuePair entry in EndpointToServiceNameMap) + { + if ((endpointsToGenerate & entry.Key) == entry.Key) { - if ((endpointsToGenerate & entry.Key) == entry.Key) + if (isFirstEntry) { - if (isFirstEntry) - { - isFirstEntry = false; - } - else - { - _sourceBuilder.AppendLine(Tokens.Comma); - } - - WriteIndent(); - _sourceBuilder.Append($"{entry.Value.ParameterName}: {entry.Value.ParameterName}"); + isFirstEntry = false; + } + else + { + _sourceBuilder.AppendLine(Tokens.Comma); } - } - _sourceBuilder.AppendLine(Tokens.CloseParen); - _depth--; + WriteIndent(); + _sourceBuilder.Append($"{entry.Value.ParameterName}: {entry.Value.ParameterName}"); + } } - private void WriteOpenCurly() - { - WriteIndent(); - _sourceBuilder.AppendLine(Tokens.OpenCurly); - } + _sourceBuilder.AppendLine(Tokens.CloseParen); + _depth--; + } - private void WriteCloseCurly() - { - WriteIndent(); - _sourceBuilder.AppendLine(Tokens.CloseCurly); - } + private void WriteOpenCurly() + { + WriteIndent(); + _sourceBuilder.AppendLine(Tokens.OpenCurly); + } - private void WriteIndent() - { - // PERF: Reuse pre-calculated indents instead of allocating a new string each time. - if (!IndentTable.TryGetValue(_depth, out string indent)) - { - var diagnostic = Diagnostic.Create(_missingIndentInTableErrorDescriptor, Location.None, _depth.ToString()); - _context.ReportDiagnostic(diagnostic); + private void WriteCloseCurly() + { + WriteIndent(); + _sourceBuilder.AppendLine(Tokens.CloseCurly); + } - indent = new string(' ', _depth * SpacesPerIndent); - } + private void WriteIndent() + { + // PERF: Reuse pre-calculated indents instead of allocating a new string each time. + if (!IndentTable.TryGetValue(_depth, out string? indent)) + { + var diagnostic = Diagnostic.Create(_missingIndentInTableErrorDescriptor, Location.None, _depth.ToString()); + _context.ReportDiagnostic(diagnostic); - _sourceBuilder.Append(indent); + indent = new string(' ', _depth * SpacesPerIndent); } + _sourceBuilder.Append(indent); + } + #pragma warning disable AV1008 // Class should not be static - private static class Tokens - { - public const string OpenCurly = "{"; - public const string CloseCurly = "}"; - public const string CloseParen = ")"; - public const string Comma = ","; - } -#pragma warning restore AV1008 // Class should not be static + private static class Tokens + { + public const string OpenCurly = "{"; + public const string CloseCurly = "}"; + public const string CloseParen = ")"; + public const string Comma = ","; } +#pragma warning restore AV1008 // Class should not be static } diff --git a/src/JsonApiDotNetCore.SourceGenerators/TypeWithAttributeSyntaxReceiver.cs b/src/JsonApiDotNetCore.SourceGenerators/TypeWithAttributeSyntaxReceiver.cs index 0fbc18a758..17c5ffefd0 100644 --- a/src/JsonApiDotNetCore.SourceGenerators/TypeWithAttributeSyntaxReceiver.cs +++ b/src/JsonApiDotNetCore.SourceGenerators/TypeWithAttributeSyntaxReceiver.cs @@ -1,41 +1,38 @@ -using System.Collections.Generic; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; -namespace JsonApiDotNetCore.SourceGenerators +namespace JsonApiDotNetCore.SourceGenerators; + +/// +/// Collects type declarations in the project that have at least one attribute on them. Because this receiver operates at the syntax level, we cannot +/// check for the expected attribute. This must be done during semantic analysis, because source code may contain any of these: +/// { } +/// +/// [ResourceAttribute] +/// public class ExampleResource2 : Identifiable { } +/// +/// [AlternateNamespaceName.Annotations.Resource] +/// public class ExampleResource3 : Identifiable { } +/// +/// [AlternateTypeName] +/// public class ExampleResource4 : Identifiable { } +/// ]]> +/// +internal sealed class TypeWithAttributeSyntaxReceiver : ISyntaxReceiver { - /// - /// Collects type declarations in the project that have at least one attribute on them. Because this receiver operates at the syntax level, we cannot - /// check for the expected attribute. This must be done during semantic analysis, because source code may contain any of these: - /// { } - /// - /// [ResourceAttribute] - /// public class ExampleResource2 : Identifiable { } - /// - /// [AlternateNamespaceName.Annotations.Resource] - /// public class ExampleResource3 : Identifiable { } - /// - /// [AlternateTypeName] - /// public class ExampleResource4 : Identifiable { } - /// ]]> - /// - /// - internal sealed class TypeWithAttributeSyntaxReceiver : ISyntaxReceiver - { - public readonly ISet TypeDeclarations = new HashSet(); + public readonly ISet TypeDeclarations = new HashSet(); - public void OnVisitSyntaxNode(SyntaxNode syntaxNode) + public void OnVisitSyntaxNode(SyntaxNode syntaxNode) + { + if (syntaxNode is TypeDeclarationSyntax { AttributeLists.Count: > 0 } typeDeclarationSyntax) { - if (syntaxNode is TypeDeclarationSyntax typeDeclarationSyntax && typeDeclarationSyntax.AttributeLists.Any()) - { - TypeDeclarations.Add(typeDeclarationSyntax); - } + TypeDeclarations.Add(typeDeclarationSyntax); } } } diff --git a/src/JsonApiDotNetCore/ArrayFactory.cs b/src/JsonApiDotNetCore/ArrayFactory.cs deleted file mode 100644 index 6ad678c64d..0000000000 --- a/src/JsonApiDotNetCore/ArrayFactory.cs +++ /dev/null @@ -1,12 +0,0 @@ -#pragma warning disable AV1008 // Class should not be static -#pragma warning disable AV1130 // Return type in method signature should be an interface to an unchangeable collection - -namespace JsonApiDotNetCore; - -internal static class ArrayFactory -{ - public static T[] Create(params T[] items) - { - return items; - } -} diff --git a/src/JsonApiDotNetCore/AtomicOperations/DefaultOperationFilter.cs b/src/JsonApiDotNetCore/AtomicOperations/DefaultOperationFilter.cs new file mode 100644 index 0000000000..fb632dd276 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/DefaultOperationFilter.cs @@ -0,0 +1,56 @@ +using System.Reflection; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.AtomicOperations; + +/// +public class DefaultOperationFilter : IAtomicOperationFilter +{ + /// + public virtual bool IsEnabled(ResourceType resourceType, WriteOperationKind writeOperation) + { + ArgumentNullException.ThrowIfNull(resourceType); + + // To match the behavior of non-operations controllers: + // If an operation is enabled on a base type, it is implicitly enabled on all derived types. + ResourceType currentResourceType = resourceType; + + while (true) + { + JsonApiEndpoints? endpoints = GetJsonApiEndpoints(currentResourceType); + bool isEnabled = endpoints != null && Contains(endpoints.Value, writeOperation); + + if (isEnabled || currentResourceType.BaseType == null) + { + return isEnabled; + } + + currentResourceType = currentResourceType.BaseType; + } + } + + protected virtual JsonApiEndpoints? GetJsonApiEndpoints(ResourceType resourceType) + { + ArgumentNullException.ThrowIfNull(resourceType); + + var resourceAttribute = resourceType.ClrType.GetCustomAttribute(); + return resourceAttribute?.GenerateControllerEndpoints; + } + + private static bool Contains(JsonApiEndpoints endpoints, WriteOperationKind writeOperation) + { + return writeOperation switch + { + WriteOperationKind.CreateResource => endpoints.HasFlag(JsonApiEndpoints.Post), + WriteOperationKind.UpdateResource => endpoints.HasFlag(JsonApiEndpoints.Patch), + WriteOperationKind.DeleteResource => endpoints.HasFlag(JsonApiEndpoints.Delete), + WriteOperationKind.SetRelationship => endpoints.HasFlag(JsonApiEndpoints.PatchRelationship), + WriteOperationKind.AddToRelationship => endpoints.HasFlag(JsonApiEndpoints.PostRelationship), + WriteOperationKind.RemoveFromRelationship => endpoints.HasFlag(JsonApiEndpoints.DeleteRelationship), + _ => false + }; + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs b/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs index be5125c414..d045bc0814 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs @@ -19,8 +19,8 @@ public sealed class EntityFrameworkCoreTransaction : IOperationsTransaction public EntityFrameworkCoreTransaction(IDbContextTransaction transaction, DbContext dbContext) { - ArgumentGuard.NotNull(transaction, nameof(transaction)); - ArgumentGuard.NotNull(dbContext, nameof(dbContext)); + ArgumentNullException.ThrowIfNull(transaction); + ArgumentNullException.ThrowIfNull(dbContext); _transaction = transaction; _dbContext = dbContext; diff --git a/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransactionFactory.cs b/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransactionFactory.cs index 96c66e12ab..2fe6959b1f 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransactionFactory.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransactionFactory.cs @@ -15,8 +15,8 @@ public sealed class EntityFrameworkCoreTransactionFactory : IOperationsTransacti public EntityFrameworkCoreTransactionFactory(IDbContextResolver dbContextResolver, IJsonApiOptions options) { - ArgumentGuard.NotNull(dbContextResolver, nameof(dbContextResolver)); - ArgumentGuard.NotNull(options, nameof(options)); + ArgumentNullException.ThrowIfNull(dbContextResolver); + ArgumentNullException.ThrowIfNull(options); _dbContextResolver = dbContextResolver; _options = options; diff --git a/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationFilter.cs b/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationFilter.cs new file mode 100644 index 0000000000..47d534c5b5 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/IAtomicOperationFilter.cs @@ -0,0 +1,42 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.AtomicOperations; + +/// +/// Determines whether an operation in an atomic:operations request can be used. For non-operations requests, see . +/// +/// +/// The default implementation relies on the usage of . If you're using explicit +/// (non-generated) controllers, register your own implementation to indicate which operations are accessible. +/// +[PublicAPI] +public interface IAtomicOperationFilter +{ + /// + /// An that always returns true. Provided for convenience, to revert to the original behavior from before + /// filtering was introduced. + /// + public static IAtomicOperationFilter AlwaysEnabled { get; } = new AlwaysEnabledOperationFilter(); + + /// + /// Determines whether the specified operation can be used in an atomic:operations request. + /// + /// + /// The targeted primary resource type of the operation. + /// + /// + /// The operation kind. + /// + bool IsEnabled(ResourceType resourceType, WriteOperationKind writeOperation); + + private sealed class AlwaysEnabledOperationFilter : IAtomicOperationFilter + { + public bool IsEnabled(ResourceType resourceType, WriteOperationKind writeOperation) + { + return true; + } + } +} diff --git a/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs b/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs index b0a1b3ee2f..bb058475e3 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs @@ -3,10 +3,10 @@ namespace JsonApiDotNetCore.AtomicOperations; -/// +/// public sealed class LocalIdTracker : ILocalIdTracker { - private readonly IDictionary _idsTracked = new Dictionary(); + private readonly Dictionary _idsTracked = new(); /// public void Reset() @@ -17,8 +17,8 @@ public void Reset() /// public void Declare(string localId, ResourceType resourceType) { - ArgumentGuard.NotNullNorEmpty(localId, nameof(localId)); - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + ArgumentException.ThrowIfNullOrEmpty(localId); + ArgumentNullException.ThrowIfNull(resourceType); AssertIsNotDeclared(localId); @@ -36,9 +36,9 @@ private void AssertIsNotDeclared(string localId) /// public void Assign(string localId, ResourceType resourceType, string stringId) { - ArgumentGuard.NotNullNorEmpty(localId, nameof(localId)); - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - ArgumentGuard.NotNullNorEmpty(stringId, nameof(stringId)); + ArgumentException.ThrowIfNullOrEmpty(localId); + ArgumentNullException.ThrowIfNull(resourceType); + ArgumentException.ThrowIfNullOrEmpty(stringId); AssertIsDeclared(localId); @@ -57,8 +57,8 @@ public void Assign(string localId, ResourceType resourceType, string stringId) /// public string GetValue(string localId, ResourceType resourceType) { - ArgumentGuard.NotNullNorEmpty(localId, nameof(localId)); - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + ArgumentException.ThrowIfNullOrEmpty(localId); + ArgumentNullException.ThrowIfNull(resourceType); AssertIsDeclared(localId); @@ -90,14 +90,9 @@ private static void AssertSameResourceType(ResourceType currentType, ResourceTyp } } - private sealed class LocalIdState + private sealed class LocalIdState(ResourceType resourceType) { - public ResourceType ResourceType { get; } + public ResourceType ResourceType { get; } = resourceType; public string? ServerId { get; set; } - - public LocalIdState(ResourceType resourceType) - { - ResourceType = resourceType; - } } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs b/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs index 9cb463ab10..92d9bd8319 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs @@ -18,8 +18,8 @@ public sealed class LocalIdValidator public LocalIdValidator(ILocalIdTracker localIdTracker, IResourceGraph resourceGraph) { - ArgumentGuard.NotNull(localIdTracker, nameof(localIdTracker)); - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + ArgumentNullException.ThrowIfNull(localIdTracker); + ArgumentNullException.ThrowIfNull(resourceGraph); _localIdTracker = localIdTracker; _resourceGraph = resourceGraph; @@ -27,7 +27,7 @@ public LocalIdValidator(ILocalIdTracker localIdTracker, IResourceGraph resourceG public void Validate(IEnumerable operations) { - ArgumentGuard.NotNull(operations, nameof(operations)); + ArgumentNullException.ThrowIfNull(operations); _localIdTracker.Reset(); diff --git a/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs index c032f78f8d..a6dc2051ec 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 { @@ -15,7 +15,7 @@ public class OperationProcessorAccessor : IOperationProcessorAccessor public OperationProcessorAccessor(IServiceProvider serviceProvider) { - ArgumentGuard.NotNull(serviceProvider, nameof(serviceProvider)); + ArgumentNullException.ThrowIfNull(serviceProvider); _serviceProvider = serviceProvider; } @@ -23,7 +23,7 @@ public OperationProcessorAccessor(IServiceProvider serviceProvider) /// public Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { - ArgumentGuard.NotNull(operation, nameof(operation)); + ArgumentNullException.ThrowIfNull(operation); IOperationProcessor processor = ResolveProcessor(operation); return processor.ProcessAsync(operation, cancellationToken); @@ -31,6 +31,8 @@ public OperationProcessorAccessor(IServiceProvider serviceProvider) protected virtual IOperationProcessor ResolveProcessor(OperationContainer operation) { + ArgumentNullException.ThrowIfNull(operation); + Type processorInterface = GetProcessorInterface(operation.Request.WriteOperation!.Value); ResourceType resourceType = operation.Request.PrimaryResourceType!; @@ -40,36 +42,15 @@ protected virtual IOperationProcessor ResolveProcessor(OperationContainer operat private static Type GetProcessorInterface(WriteOperationKind writeOperation) { - switch (writeOperation) + return writeOperation switch { - case WriteOperationKind.CreateResource: - { - return typeof(ICreateProcessor<,>); - } - case WriteOperationKind.UpdateResource: - { - return typeof(IUpdateProcessor<,>); - } - case WriteOperationKind.DeleteResource: - { - return typeof(IDeleteProcessor<,>); - } - case WriteOperationKind.SetRelationship: - { - return typeof(ISetRelationshipProcessor<,>); - } - case WriteOperationKind.AddToRelationship: - { - return typeof(IAddToRelationshipProcessor<,>); - } - case WriteOperationKind.RemoveFromRelationship: - { - return typeof(IRemoveFromRelationshipProcessor<,>); - } - default: - { - throw new NotSupportedException($"Unknown write operation kind '{writeOperation}'."); - } - } + WriteOperationKind.CreateResource => typeof(ICreateProcessor<,>), + WriteOperationKind.UpdateResource => typeof(IUpdateProcessor<,>), + WriteOperationKind.DeleteResource => typeof(IDeleteProcessor<,>), + WriteOperationKind.SetRelationship => typeof(ISetRelationshipProcessor<,>), + WriteOperationKind.AddToRelationship => typeof(IAddToRelationshipProcessor<,>), + WriteOperationKind.RemoveFromRelationship => typeof(IRemoveFromRelationshipProcessor<,>), + _ => throw new NotSupportedException($"Unknown write operation kind '{writeOperation}'.") + }; } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs index 6524252abf..f3d0b22256 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs @@ -2,13 +2,13 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Queries.Internal; +using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; namespace JsonApiDotNetCore.AtomicOperations; -/// +/// [PublicAPI] public class OperationsProcessor : IOperationsProcessor { @@ -25,13 +25,13 @@ public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccesso ILocalIdTracker localIdTracker, IResourceGraph resourceGraph, IJsonApiRequest request, ITargetedFields targetedFields, ISparseFieldSetCache sparseFieldSetCache) { - ArgumentGuard.NotNull(operationProcessorAccessor, nameof(operationProcessorAccessor)); - ArgumentGuard.NotNull(operationsTransactionFactory, nameof(operationsTransactionFactory)); - ArgumentGuard.NotNull(localIdTracker, nameof(localIdTracker)); - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); - ArgumentGuard.NotNull(sparseFieldSetCache, nameof(sparseFieldSetCache)); + ArgumentNullException.ThrowIfNull(operationProcessorAccessor); + ArgumentNullException.ThrowIfNull(operationsTransactionFactory); + ArgumentNullException.ThrowIfNull(localIdTracker); + ArgumentNullException.ThrowIfNull(resourceGraph); + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(targetedFields); + ArgumentNullException.ThrowIfNull(sparseFieldSetCache); _operationProcessorAccessor = operationProcessorAccessor; _operationsTransactionFactory = operationsTransactionFactory; @@ -46,12 +46,12 @@ public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccesso /// public virtual async Task> ProcessAsync(IList operations, CancellationToken cancellationToken) { - ArgumentGuard.NotNull(operations, nameof(operations)); + ArgumentNullException.ThrowIfNull(operations); _localIdValidator.Validate(operations); _localIdTracker.Reset(); - var results = new List(); + List results = []; await using IOperationsTransaction transaction = await _operationsTransactionFactory.BeginTransactionAsync(cancellationToken); @@ -101,6 +101,8 @@ public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccesso protected virtual async Task ProcessOperationAsync(OperationContainer operation, CancellationToken cancellationToken) { + ArgumentNullException.ThrowIfNull(operation); + cancellationToken.ThrowIfCancellationRequested(); TrackLocalIdsForOperation(operation); @@ -113,6 +115,8 @@ public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccesso protected void TrackLocalIdsForOperation(OperationContainer operation) { + ArgumentNullException.ThrowIfNull(operation); + if (operation.Request.WriteOperation == WriteOperationKind.CreateResource) { DeclareLocalId(operation.Resource, operation.Request.PrimaryResourceType!); diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs index fc16847eec..e84756120a 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 @@ -13,7 +13,7 @@ public class AddToRelationshipProcessor : IAddToRelationshipProc public AddToRelationshipProcessor(IAddToRelationshipService service) { - ArgumentGuard.NotNull(service, nameof(service)); + ArgumentNullException.ThrowIfNull(service); _service = service; } @@ -21,12 +21,12 @@ public AddToRelationshipProcessor(IAddToRelationshipService serv /// public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { - ArgumentGuard.NotNull(operation, nameof(operation)); + ArgumentNullException.ThrowIfNull(operation); var leftId = (TId)operation.Resource.GetTypedId(); ISet rightResourceIds = operation.GetSecondaryResources(); - await _service.AddToToManyRelationshipAsync(leftId, operation.Request.Relationship!.PublicName, rightResourceIds, cancellationToken); + await _service.AddToToManyRelationshipAsync(leftId!, operation.Request.Relationship!.PublicName, rightResourceIds, cancellationToken); return null; } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs index b06ebd626e..c7bf3c9b77 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 @@ -14,8 +14,8 @@ public class CreateProcessor : ICreateProcessor public CreateProcessor(ICreateService service, ILocalIdTracker localIdTracker) { - ArgumentGuard.NotNull(service, nameof(service)); - ArgumentGuard.NotNull(localIdTracker, nameof(localIdTracker)); + ArgumentNullException.ThrowIfNull(service); + ArgumentNullException.ThrowIfNull(localIdTracker); _service = service; _localIdTracker = localIdTracker; @@ -24,7 +24,7 @@ public CreateProcessor(ICreateService service, ILocalIdTracker l /// public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { - ArgumentGuard.NotNull(operation, nameof(operation)); + ArgumentNullException.ThrowIfNull(operation); TResource? newResource = await _service.CreateAsync((TResource)operation.Resource, cancellationToken); diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs index e4001b75c1..5709188a8c 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 @@ -13,7 +13,7 @@ public class DeleteProcessor : IDeleteProcessor public DeleteProcessor(IDeleteService service) { - ArgumentGuard.NotNull(service, nameof(service)); + ArgumentNullException.ThrowIfNull(service); _service = service; } @@ -21,10 +21,10 @@ public DeleteProcessor(IDeleteService service) /// public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { - ArgumentGuard.NotNull(operation, nameof(operation)); + ArgumentNullException.ThrowIfNull(operation); var id = (TId)operation.Resource.GetTypedId(); - await _service.DeleteAsync(id, cancellationToken); + await _service.DeleteAsync(id!, cancellationToken); return null; } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IAddToRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IAddToRelationshipProcessor.cs index 8b9990342a..91d23e3358 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IAddToRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IAddToRelationshipProcessor.cs @@ -16,6 +16,4 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors; /// [PublicAPI] public interface IAddToRelationshipProcessor : IOperationProcessor - where TResource : class, IIdentifiable -{ -} + where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateProcessor.cs index 9fd1de2186..6cc04043f3 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateProcessor.cs @@ -16,6 +16,4 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors; /// [PublicAPI] public interface ICreateProcessor : IOperationProcessor - where TResource : class, IIdentifiable -{ -} + where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IDeleteProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IDeleteProcessor.cs index 67627cd8c0..42f5f71c14 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IDeleteProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IDeleteProcessor.cs @@ -16,6 +16,4 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors; /// [PublicAPI] public interface IDeleteProcessor : IOperationProcessor - where TResource : class, IIdentifiable -{ -} + where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveFromRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveFromRelationshipProcessor.cs index 6492c992f1..2dc7bdb17d 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveFromRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveFromRelationshipProcessor.cs @@ -12,6 +12,4 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors; /// [PublicAPI] public interface IRemoveFromRelationshipProcessor : IOperationProcessor - where TResource : class, IIdentifiable -{ -} + where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/ISetRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/ISetRelationshipProcessor.cs index dd950d203d..7928aa76b0 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/ISetRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/ISetRelationshipProcessor.cs @@ -16,6 +16,4 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors; /// [PublicAPI] public interface ISetRelationshipProcessor : IOperationProcessor - where TResource : class, IIdentifiable -{ -} + where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateProcessor.cs index 6051837749..77b83f65f7 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateProcessor.cs @@ -17,6 +17,4 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors; /// [PublicAPI] public interface IUpdateProcessor : IOperationProcessor - where TResource : class, IIdentifiable -{ -} + where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs index 493ed2066f..81c4eb93ee 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 @@ -13,7 +13,7 @@ public class RemoveFromRelationshipProcessor : IRemoveFromRelati public RemoveFromRelationshipProcessor(IRemoveFromRelationshipService service) { - ArgumentGuard.NotNull(service, nameof(service)); + ArgumentNullException.ThrowIfNull(service); _service = service; } @@ -21,12 +21,12 @@ public RemoveFromRelationshipProcessor(IRemoveFromRelationshipService public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { - ArgumentGuard.NotNull(operation, nameof(operation)); + ArgumentNullException.ThrowIfNull(operation); var leftId = (TId)operation.Resource.GetTypedId(); ISet rightResourceIds = operation.GetSecondaryResources(); - await _service.RemoveFromToManyRelationshipAsync(leftId, operation.Request.Relationship!.PublicName, rightResourceIds, cancellationToken); + await _service.RemoveFromToManyRelationshipAsync(leftId!, operation.Request.Relationship!.PublicName, rightResourceIds, cancellationToken); return null; } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs index 5eb09ccbc3..913068a26c 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs @@ -5,17 +5,16 @@ namespace JsonApiDotNetCore.AtomicOperations.Processors; -/// +/// [PublicAPI] public class SetRelationshipProcessor : ISetRelationshipProcessor where TResource : class, IIdentifiable { - private readonly CollectionConverter _collectionConverter = new(); private readonly ISetRelationshipService _service; public SetRelationshipProcessor(ISetRelationshipService service) { - ArgumentGuard.NotNull(service, nameof(service)); + ArgumentNullException.ThrowIfNull(service); _service = service; } @@ -23,12 +22,12 @@ public SetRelationshipProcessor(ISetRelationshipService service) /// public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { - ArgumentGuard.NotNull(operation, nameof(operation)); + ArgumentNullException.ThrowIfNull(operation); var leftId = (TId)operation.Resource.GetTypedId(); object? rightValue = GetRelationshipRightValue(operation); - await _service.SetRelationshipAsync(leftId, operation.Request.Relationship!.PublicName, rightValue, cancellationToken); + await _service.SetRelationshipAsync(leftId!, operation.Request.Relationship!.PublicName, rightValue, cancellationToken); return null; } @@ -40,7 +39,7 @@ public SetRelationshipProcessor(ISetRelationshipService service) if (relationship is HasManyAttribute) { - IReadOnlyCollection rightResources = _collectionConverter.ExtractResources(rightValue); + IReadOnlyCollection rightResources = CollectionConverter.Instance.ExtractResources(rightValue); return rightResources.ToHashSet(IdentifiableComparer.Instance); } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs index a02ac2d3ff..cf66c70462 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 @@ -13,7 +13,7 @@ public class UpdateProcessor : IUpdateProcessor public UpdateProcessor(IUpdateService service) { - ArgumentGuard.NotNull(service, nameof(service)); + ArgumentNullException.ThrowIfNull(service); _service = service; } @@ -21,10 +21,10 @@ public UpdateProcessor(IUpdateService service) /// public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) { - ArgumentGuard.NotNull(operation, nameof(operation)); + ArgumentNullException.ThrowIfNull(operation); var resource = (TResource)operation.Resource; - TResource? updated = await _service.UpdateAsync(resource.Id, resource, cancellationToken); + TResource? updated = await _service.UpdateAsync(resource.Id!, resource, cancellationToken); return updated == null ? null : operation.WithResource(updated); } diff --git a/src/JsonApiDotNetCore/AtomicOperations/RevertRequestStateOnDispose.cs b/src/JsonApiDotNetCore/AtomicOperations/RevertRequestStateOnDispose.cs index 453f78f1f2..82b0641c34 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/RevertRequestStateOnDispose.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/RevertRequestStateOnDispose.cs @@ -11,12 +11,12 @@ internal sealed class RevertRequestStateOnDispose : IDisposable private readonly IJsonApiRequest _sourceRequest; private readonly ITargetedFields? _sourceTargetedFields; - private readonly IJsonApiRequest _backupRequest = new JsonApiRequest(); - private readonly ITargetedFields _backupTargetedFields = new TargetedFields(); + private readonly JsonApiRequest _backupRequest = new(); + private readonly TargetedFields _backupTargetedFields = new(); public RevertRequestStateOnDispose(IJsonApiRequest request, ITargetedFields? targetedFields) { - ArgumentGuard.NotNull(request, nameof(request)); + ArgumentNullException.ThrowIfNull(request); _sourceRequest = request; _backupRequest.CopyFrom(request); diff --git a/src/JsonApiDotNetCore/CollectionExtensions.cs b/src/JsonApiDotNetCore/CollectionExtensions.cs index a7f5e72ab6..ca46953bfc 100644 --- a/src/JsonApiDotNetCore/CollectionExtensions.cs +++ b/src/JsonApiDotNetCore/CollectionExtensions.cs @@ -16,10 +16,25 @@ public static bool IsNullOrEmpty([NotNullWhen(false)] this IEnumerable? so return !source.Any(); } + public static int FindIndex(this IReadOnlyList source, T item) + { + ArgumentNullException.ThrowIfNull(source); + + for (int index = 0; index < source.Count; index++) + { + if (EqualityComparer.Default.Equals(source[index], item)) + { + return index; + } + } + + return -1; + } + public static int FindIndex(this IReadOnlyList source, Predicate match) { - ArgumentGuard.NotNull(source, nameof(source)); - ArgumentGuard.NotNull(match, nameof(match)); + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(match); for (int index = 0; index < source.Count; index++) { @@ -32,6 +47,18 @@ public static int FindIndex(this IReadOnlyList source, Predicate match) return -1; } + public static IEnumerable ToEnumerable(this LinkedListNode? startNode) + { + LinkedListNode? current = startNode; + + while (current != null) + { + yield return current.Value; + + current = current.Next; + } + } + public static bool DictionaryEqual(this IReadOnlyDictionary? first, IReadOnlyDictionary? second, IEqualityComparer? valueComparer = null) { @@ -70,7 +97,7 @@ public static bool DictionaryEqual(this IReadOnlyDictionary EmptyIfNull(this IEnumerable? source) { - return source ?? Enumerable.Empty(); + return source ?? Array.Empty(); } public static IEnumerable WhereNotNull(this IEnumerable source) @@ -79,15 +106,4 @@ public static IEnumerable WhereNotNull(this IEnumerable source) return source.Where(element => element is not null)!; #pragma warning restore AV1250 // Evaluate LINQ query before returning it } - - public static void AddRange(this ICollection source, IEnumerable itemsToAdd) - { - ArgumentGuard.NotNull(source, nameof(source)); - ArgumentGuard.NotNull(itemsToAdd, nameof(itemsToAdd)); - - foreach (T item in itemsToAdd) - { - source.Add(item); - } - } } diff --git a/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs b/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs index a941e27218..fb69fa5ae5 100644 --- a/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs +++ b/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs @@ -1,6 +1,8 @@ +using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; namespace JsonApiDotNetCore.Configuration; @@ -22,7 +24,8 @@ public static class ApplicationBuilderExtensions /// public static void UseJsonApi(this IApplicationBuilder builder) { - ArgumentGuard.NotNull(builder, nameof(builder)); + ArgumentNullException.ThrowIfNull(builder); + AssertAspNetCoreOpenApiIsNotRegistered(builder.ApplicationServices); using (IServiceScope scope = builder.ApplicationServices.CreateScope()) { @@ -46,4 +49,34 @@ public static void UseJsonApi(this IApplicationBuilder builder) builder.UseMiddleware(); } + + private static void AssertAspNetCoreOpenApiIsNotRegistered(IServiceProvider serviceProvider) + { + Type? optionsType = TryLoadOptionsType(); + + if (optionsType != null) + { + Type configureType = typeof(IConfigureOptions<>).MakeGenericType(optionsType); + object? configureInstance = serviceProvider.GetService(configureType); + + if (configureInstance != null) + { + throw new InvalidConfigurationException("JsonApiDotNetCore is incompatible with ASP.NET OpenAPI. " + + "Remove 'services.AddOpenApi()', or replace it by calling 'services.AddOpenApiForJsonApi()' after 'services.AddJsonApi()' " + + "from the JsonApiDotNetCore.OpenApi.Swashbuckle NuGet package."); + } + } + } + + private static Type? TryLoadOptionsType() + { + try + { + return Type.GetType("Microsoft.AspNetCore.OpenApi.OpenApiOptions, Microsoft.AspNetCore.OpenApi"); + } + catch (FileLoadException) + { + return null; + } + } } diff --git a/src/JsonApiDotNetCore/Configuration/DefaultJsonApiApplicationBuilderEvents.cs b/src/JsonApiDotNetCore/Configuration/DefaultJsonApiApplicationBuilderEvents.cs new file mode 100644 index 0000000000..c1bea78418 --- /dev/null +++ b/src/JsonApiDotNetCore/Configuration/DefaultJsonApiApplicationBuilderEvents.cs @@ -0,0 +1,20 @@ +using JsonApiDotNetCore.Serialization.JsonConverters; + +namespace JsonApiDotNetCore.Configuration; + +internal sealed class DefaultJsonApiApplicationBuilderEvents : IJsonApiApplicationBuilderEvents +{ + private readonly IJsonApiOptions _options; + + public DefaultJsonApiApplicationBuilderEvents(IJsonApiOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + _options = options; + } + + public void ResourceGraphBuilt(IResourceGraph resourceGraph) + { + _options.SerializerOptions.Converters.Add(new ResourceObjectConverter(resourceGraph)); + } +} diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiApplicationBuilderEvents.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiApplicationBuilderEvents.cs new file mode 100644 index 0000000000..525240bfc2 --- /dev/null +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiApplicationBuilderEvents.cs @@ -0,0 +1,6 @@ +namespace JsonApiDotNetCore.Configuration; + +internal interface IJsonApiApplicationBuilderEvents +{ + void ResourceGraphBuilt(IResourceGraph resourceGraph); +} diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs index 597d22294d..7141125e40 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs @@ -1,5 +1,8 @@ using System.Data; using System.Text.Json; +using JetBrains.Annotations; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; @@ -14,51 +17,66 @@ public interface IJsonApiOptions /// The URL prefix to use for exposed endpoints. /// /// - /// options.Namespace = "api/v1"; + /// /// string? Namespace { get; } /// - /// Specifies the default query string capabilities that can be used on exposed 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; } /// - /// Indicates whether responses should contain a jsonapi object that contains the highest JSON:API version supported. False by default. + /// 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 . This setting + /// can be overruled per relationship using . + /// + HasManyCapabilities DefaultHasManyCapabilities { get; } + + /// + /// 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. /// /// - /// + /// - /// + /// ]]> + /// + /// ]]> /// bool UseRelativeLinks { get; } @@ -82,12 +100,12 @@ public interface IJsonApiOptions LinkTypes RelationshipLinks { get; } /// - /// 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; } /// - /// The page size (10 by default) that is used when not specified in query string. Set to null to not use paging by default. + /// The page size (10 by default) that is used when not specified in query string. Set to null to not use pagination by default. /// PageSize? DefaultPageSize { get; } @@ -102,28 +120,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; } @@ -144,6 +174,18 @@ public interface IJsonApiOptions /// IsolationLevel? TransactionIsolationLevel { get; } + /// + /// Lists the JSON:API extensions that are turned on. Empty by default, but if your project contains a controller that derives from + /// , the and + /// extensions are automatically added. + /// + /// + /// To implement a custom JSON:API extension, add it here and override to indicate which + /// combinations of extensions are available, depending on the current endpoint. Use to obtain the active + /// extensions when implementing extension-specific logic. + /// + IReadOnlySet Extensions { get; } + /// /// Enables to customize the settings that are used by the . /// diff --git a/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs index a98b74c3fe..a2c4f3283c 100644 --- a/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs +++ b/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs @@ -65,7 +65,7 @@ IReadOnlyCollection GetFields(Expression /// /// Should be of the form: new { resource.attribute1, resource.Attribute2 } + /// (TResource resource) => new { resource.Attribute1, resource.Attribute2 } /// ]]> /// IReadOnlyCollection GetAttributes(Expression> selector) diff --git a/src/JsonApiDotNetCore/Configuration/InjectablesAssemblyScanner.cs b/src/JsonApiDotNetCore/Configuration/InjectablesAssemblyScanner.cs new file mode 100644 index 0000000000..3ac71053b0 --- /dev/null +++ b/src/JsonApiDotNetCore/Configuration/InjectablesAssemblyScanner.cs @@ -0,0 +1,129 @@ +using System.Reflection; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace JsonApiDotNetCore.Configuration; + +/// +/// Scans assemblies for injectables (types that implement , +/// or ) and registers them in the IoC container. +/// +internal sealed class InjectablesAssemblyScanner +{ + internal static readonly HashSet ServiceUnboundInterfaces = + [ + typeof(IResourceService<,>), + typeof(IResourceCommandService<,>), + typeof(IResourceQueryService<,>), + typeof(IGetAllService<,>), + typeof(IGetByIdService<,>), + typeof(IGetSecondaryService<,>), + typeof(IGetRelationshipService<,>), + typeof(ICreateService<,>), + typeof(IAddToRelationshipService<,>), + typeof(IUpdateService<,>), + typeof(ISetRelationshipService<,>), + typeof(IDeleteService<,>), + typeof(IRemoveFromRelationshipService<,>) + ]; + + internal static readonly HashSet RepositoryUnboundInterfaces = + [ + typeof(IResourceRepository<,>), + typeof(IResourceWriteRepository<,>), + typeof(IResourceReadRepository<,>) + ]; + + internal static readonly HashSet ResourceDefinitionUnboundInterfaces = [typeof(IResourceDefinition<,>)]; + + private readonly ResourceDescriptorAssemblyCache _assemblyCache; + private readonly IServiceCollection _services; + private readonly TypeLocator _typeLocator = new(); + + public InjectablesAssemblyScanner(ResourceDescriptorAssemblyCache assemblyCache, IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(assemblyCache); + ArgumentNullException.ThrowIfNull(services); + + _assemblyCache = assemblyCache; + _services = services; + } + + public void DiscoverInjectables() + { + IReadOnlyCollection descriptors = _assemblyCache.GetResourceDescriptors(); + IReadOnlyCollection assemblies = _assemblyCache.GetAssemblies(); + + foreach (Assembly assembly in assemblies) + { + AddDbContextResolvers(assembly); + AddInjectables(descriptors, assembly); + } + } + + private void AddDbContextResolvers(Assembly assembly) + { + IEnumerable dbContextTypes = _typeLocator.GetDerivedTypes(assembly, typeof(DbContext)); + + foreach (Type dbContextType in dbContextTypes) + { + Type dbContextResolverClosedType = typeof(DbContextResolver<>).MakeGenericType(dbContextType); + _services.TryAddScoped(typeof(IDbContextResolver), dbContextResolverClosedType); + } + } + + private void AddInjectables(IEnumerable resourceDescriptors, Assembly assembly) + { + foreach (ResourceDescriptor resourceDescriptor in resourceDescriptors) + { + AddServices(assembly, resourceDescriptor); + AddRepositories(assembly, resourceDescriptor); + AddResourceDefinitions(assembly, resourceDescriptor); + } + } + + private void AddServices(Assembly assembly, ResourceDescriptor resourceDescriptor) + { + foreach (Type serviceUnboundInterface in ServiceUnboundInterfaces) + { + RegisterImplementations(assembly, serviceUnboundInterface, resourceDescriptor); + } + } + + private void AddRepositories(Assembly assembly, ResourceDescriptor resourceDescriptor) + { + foreach (Type repositoryUnboundInterface in RepositoryUnboundInterfaces) + { + RegisterImplementations(assembly, repositoryUnboundInterface, resourceDescriptor); + } + } + + private void AddResourceDefinitions(Assembly assembly, ResourceDescriptor resourceDescriptor) + { + foreach (Type resourceDefinitionUnboundInterface in ResourceDefinitionUnboundInterfaces) + { + RegisterImplementations(assembly, resourceDefinitionUnboundInterface, resourceDescriptor); + } + } + + private void RegisterImplementations(Assembly assembly, Type interfaceType, ResourceDescriptor resourceDescriptor) + { + Type[] typeArguments = + [ + resourceDescriptor.ResourceClrType, + resourceDescriptor.IdClrType + ]; + + (Type implementationType, Type serviceInterface)? result = _typeLocator.GetContainerRegistrationFromAssembly(assembly, interfaceType, typeArguments); + + if (result != null) + { + (Type implementationType, Type serviceInterface) = result.Value; + _services.TryAddScoped(serviceInterface, implementationType); + } + } +} diff --git a/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs b/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs index 8e5f3f15a3..4527afdcbe 100644 --- a/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs +++ b/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs @@ -6,20 +6,20 @@ namespace JsonApiDotNetCore.Configuration; -/// +/// [PublicAPI] public sealed class InverseNavigationResolver : IInverseNavigationResolver { private readonly IResourceGraph _resourceGraph; - private readonly IEnumerable _dbContextResolvers; + private readonly IDbContextResolver[] _dbContextResolvers; public InverseNavigationResolver(IResourceGraph resourceGraph, IEnumerable dbContextResolvers) { - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - ArgumentGuard.NotNull(dbContextResolvers, nameof(dbContextResolvers)); + ArgumentNullException.ThrowIfNull(resourceGraph); + ArgumentNullException.ThrowIfNull(dbContextResolvers); _resourceGraph = resourceGraph; - _dbContextResolvers = dbContextResolvers; + _dbContextResolvers = dbContextResolvers as IDbContextResolver[] ?? dbContextResolvers.ToArray(); } /// @@ -34,19 +34,19 @@ public void Resolve() private void Resolve(DbContext dbContext) { - foreach (ResourceType resourceType in _resourceGraph.GetResourceTypes().Where(resourceType => resourceType.Relationships.Any())) + foreach (ResourceType resourceType in _resourceGraph.GetResourceTypes().Where(resourceType => resourceType.Relationships.Count > 0)) { IEntityType? entityType = dbContext.Model.FindEntityType(resourceType.ClrType); if (entityType != null) { - IDictionary navigationMap = GetNavigations(entityType); + Dictionary navigationMap = GetNavigations(entityType); ResolveRelationships(resourceType.Relationships, navigationMap); } } } - private static IDictionary GetNavigations(IEntityType entityType) + private static Dictionary GetNavigations(IEntityType entityType) { // @formatter:wrap_chained_method_calls chop_always @@ -58,7 +58,7 @@ private static IDictionary GetNavigations(IEntityType e // @formatter:wrap_chained_method_calls restore } - private void ResolveRelationships(IReadOnlyCollection relationships, IDictionary navigationMap) + private void ResolveRelationships(IReadOnlyCollection relationships, Dictionary navigationMap) { foreach (RelationshipAttribute relationship in relationships) { diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 7cd4307ad1..65646b7697 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -2,12 +2,11 @@ using JsonApiDotNetCore.AtomicOperations.Processors; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Internal; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.Queries.QueryableBuilding; using JsonApiDotNetCore.QueryStrings; -using JsonApiDotNetCore.QueryStrings.Internal; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.JsonConverters; using JsonApiDotNetCore.Serialization.Request; using JsonApiDotNetCore.Serialization.Request.Adapters; using JsonApiDotNetCore.Serialization.Response; @@ -23,33 +22,25 @@ namespace JsonApiDotNetCore.Configuration; /// -/// A utility class that builds a JsonApi application. It registers all required services and allows the user to override parts of the startup +/// A utility class that builds a JSON:API application. It registers all required services and allows the user to override parts of the startup /// configuration. /// -internal sealed class JsonApiApplicationBuilder : IJsonApiApplicationBuilder, IDisposable +internal sealed class JsonApiApplicationBuilder : IJsonApiApplicationBuilder { - private readonly JsonApiOptions _options = new(); private readonly IServiceCollection _services; private readonly IMvcCoreBuilder _mvcBuilder; - private readonly ResourceGraphBuilder _resourceGraphBuilder; - private readonly ServiceDiscoveryFacade _serviceDiscoveryFacade; - private readonly ServiceProvider _intermediateProvider; + private readonly JsonApiOptions _options = new(); + private readonly ResourceDescriptorAssemblyCache _assemblyCache = new(); public Action? ConfigureMvcOptions { get; set; } public JsonApiApplicationBuilder(IServiceCollection services, IMvcCoreBuilder mvcBuilder) { - ArgumentGuard.NotNull(services, nameof(services)); - ArgumentGuard.NotNull(mvcBuilder, nameof(mvcBuilder)); + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(mvcBuilder); _services = services; _mvcBuilder = mvcBuilder; - _intermediateProvider = services.BuildServiceProvider(); - - var loggerFactory = _intermediateProvider.GetRequiredService(); - - _resourceGraphBuilder = new ResourceGraphBuilder(_options, loggerFactory); - _serviceDiscoveryFacade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, loggerFactory); } /// @@ -61,35 +52,52 @@ public void ConfigureJsonApiOptions(Action? configureOptions) } /// - /// Executes the action provided by the user to configure . + /// Executes the action provided by the user to configure auto-discovery. /// public void ConfigureAutoDiscovery(Action? configureAutoDiscovery) { - configureAutoDiscovery?.Invoke(_serviceDiscoveryFacade); + if (configureAutoDiscovery != null) + { + var facade = new ServiceDiscoveryFacade(_assemblyCache); + configureAutoDiscovery.Invoke(facade); + } } /// - /// Configures and builds the resource graph with resources from the provided sources and adds it to the DI container. + /// Configures and builds the resource graph with resources from the provided sources and adds them to the IoC container. /// public void ConfigureResourceGraph(ICollection dbContextTypes, Action? configureResourceGraph) { - ArgumentGuard.NotNull(dbContextTypes, nameof(dbContextTypes)); - - _serviceDiscoveryFacade.DiscoverResources(); + ArgumentNullException.ThrowIfNull(dbContextTypes); - foreach (Type dbContextType in dbContextTypes) + _services.TryAddSingleton(serviceProvider => { - var dbContext = (DbContext)_intermediateProvider.GetRequiredService(dbContextType); - _resourceGraphBuilder.Add(dbContext); - } + var loggerFactory = serviceProvider.GetRequiredService(); + var events = serviceProvider.GetRequiredService(); + + var resourceGraphBuilder = new ResourceGraphBuilder(_options, loggerFactory); - configureResourceGraph?.Invoke(_resourceGraphBuilder); + var scanner = new ResourcesAssemblyScanner(_assemblyCache, resourceGraphBuilder); + scanner.DiscoverResources(); - IResourceGraph resourceGraph = _resourceGraphBuilder.Build(); + if (dbContextTypes.Count > 0) + { + using IServiceScope scope = serviceProvider.CreateScope(); + + foreach (Type dbContextType in dbContextTypes) + { + var dbContext = (DbContext)scope.ServiceProvider.GetRequiredService(dbContextType); + resourceGraphBuilder.Add(dbContext); + } + } - _options.SerializerOptions.Converters.Add(new ResourceObjectConverter(resourceGraph)); + configureResourceGraph?.Invoke(resourceGraphBuilder); - _services.AddSingleton(resourceGraph); + IResourceGraph resourceGraph = resourceGraphBuilder.Build(); + events.ResourceGraphBuilt(resourceGraph); + + return resourceGraph; + }); } /// @@ -109,40 +117,41 @@ public void ConfigureMvc() if (_options.ValidateModelState) { _mvcBuilder.AddDataAnnotations(); - _services.AddSingleton(); + _services.Replace(new ServiceDescriptor(typeof(IModelMetadataProvider), typeof(JsonApiModelMetadataProvider), ServiceLifetime.Singleton)); } } /// - /// Discovers DI registrable services in the assemblies marked for discovery. + /// Registers injectables in the IoC container found in assemblies marked for auto-discovery. /// public void DiscoverInjectables() { - _serviceDiscoveryFacade.DiscoverInjectables(); + var scanner = new InjectablesAssemblyScanner(_assemblyCache, _services); + scanner.DiscoverInjectables(); } /// - /// Registers the remaining internals. + /// Registers the remaining internals in the IoC container. /// public void ConfigureServiceContainer(ICollection dbContextTypes) { - ArgumentGuard.NotNull(dbContextTypes, nameof(dbContextTypes)); + ArgumentNullException.ThrowIfNull(dbContextTypes); - if (dbContextTypes.Any()) + if (dbContextTypes.Count > 0) { - _services.AddScoped(typeof(DbContextResolver<>)); + _services.TryAddScoped(typeof(DbContextResolver<>)); foreach (Type dbContextType in dbContextTypes) { Type dbContextResolverClosedType = typeof(DbContextResolver<>).MakeGenericType(dbContextType); - _services.AddScoped(typeof(IDbContextResolver), dbContextResolverClosedType); + _services.TryAddScoped(typeof(IDbContextResolver), dbContextResolverClosedType); } - _services.AddScoped(); + _services.TryAddScoped(); } else { - _services.AddScoped(); + _services.TryAddScoped(); } AddResourceLayer(); @@ -153,51 +162,62 @@ public void ConfigureServiceContainer(ICollection dbContextTypes) AddQueryStringLayer(); AddOperationsLayer(); - _services.AddScoped(typeof(IResourceChangeTracker<>), typeof(ResourceChangeTracker<>)); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); + _services.TryAddScoped(typeof(IResourceChangeTracker<>), typeof(ResourceChangeTracker<>)); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddSingleton(); + _services.TryAddSingleton(); } private void AddMiddlewareLayer() { - _services.AddSingleton(_options); - _services.AddSingleton(this); - _services.AddSingleton(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddSingleton(); - _services.AddSingleton(); - _services.AddSingleton(); - _services.AddSingleton(sp => sp.GetRequiredService()); - _services.AddSingleton(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); + _services.TryAddSingleton(_options); + _services.TryAddSingleton(this); + _services.TryAddSingleton(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddSingleton(); + _services.TryAddSingleton(); + _services.TryAddSingleton(); + _services.TryAddSingleton(provider => provider.GetRequiredService()); + _services.TryAddSingleton(); + _services.TryAddSingleton(); + _services.TryAddSingleton(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); } private void AddResourceLayer() { - RegisterImplementationForInterfaces(ServiceDiscoveryFacade.ResourceDefinitionUnboundInterfaces, typeof(JsonApiResourceDefinition<,>)); + RegisterImplementationForInterfaces(InjectablesAssemblyScanner.ResourceDefinitionUnboundInterfaces, typeof(JsonApiResourceDefinition<,>)); - _services.AddScoped(); - _services.AddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); } private void AddRepositoryLayer() { - RegisterImplementationForInterfaces(ServiceDiscoveryFacade.RepositoryUnboundInterfaces, typeof(EntityFrameworkCoreRepository<,>)); + RegisterImplementationForInterfaces(InjectablesAssemblyScanner.RepositoryUnboundInterfaces, typeof(EntityFrameworkCoreRepository<,>)); - _services.AddScoped(); + _services.TryAddScoped(); + + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.TryAddTransient(); } private void AddServiceLayer() { - RegisterImplementationForInterfaces(ServiceDiscoveryFacade.ServiceUnboundInterfaces, typeof(JsonApiResourceService<,>)); + RegisterImplementationForInterfaces(InjectablesAssemblyScanner.ServiceUnboundInterfaces, typeof(JsonApiResourceService<,>)); } private void RegisterImplementationForInterfaces(HashSet unboundInterfaces, Type unboundImplementationType) @@ -210,12 +230,20 @@ private void RegisterImplementationForInterfaces(HashSet unboundInterfaces private void AddQueryStringLayer() { - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.TryAddTransient(); + + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); RegisterDependentService(); RegisterDependentService(); @@ -231,54 +259,50 @@ private void AddQueryStringLayer() RegisterDependentService(); RegisterDependentService(); - _services.AddScoped(); - _services.AddSingleton(); + _services.TryAddScoped(); + _services.TryAddSingleton(); } private void RegisterDependentService() where TCollectionElement : class where TElementToAdd : TCollectionElement { - _services.AddScoped(serviceProvider => serviceProvider.GetRequiredService()); + _services.AddScoped(provider => provider.GetRequiredService()); } private void AddSerializationLayer() { - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddSingleton(); - _services.AddSingleton(); - _services.AddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddSingleton(); + _services.TryAddSingleton(); + _services.TryAddScoped(); } private void AddOperationsLayer() { - _services.AddScoped(typeof(ICreateProcessor<,>), typeof(CreateProcessor<,>)); - _services.AddScoped(typeof(IUpdateProcessor<,>), typeof(UpdateProcessor<,>)); - _services.AddScoped(typeof(IDeleteProcessor<,>), typeof(DeleteProcessor<,>)); - _services.AddScoped(typeof(IAddToRelationshipProcessor<,>), typeof(AddToRelationshipProcessor<,>)); - _services.AddScoped(typeof(ISetRelationshipProcessor<,>), typeof(SetRelationshipProcessor<,>)); - _services.AddScoped(typeof(IRemoveFromRelationshipProcessor<,>), typeof(RemoveFromRelationshipProcessor<,>)); - - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - } - - public void Dispose() - { - _intermediateProvider.Dispose(); + _services.TryAddScoped(typeof(ICreateProcessor<,>), typeof(CreateProcessor<,>)); + _services.TryAddScoped(typeof(IUpdateProcessor<,>), typeof(UpdateProcessor<,>)); + _services.TryAddScoped(typeof(IDeleteProcessor<,>), typeof(DeleteProcessor<,>)); + _services.TryAddScoped(typeof(IAddToRelationshipProcessor<,>), typeof(AddToRelationshipProcessor<,>)); + _services.TryAddScoped(typeof(ISetRelationshipProcessor<,>), typeof(SetRelationshipProcessor<,>)); + _services.TryAddScoped(typeof(IRemoveFromRelationshipProcessor<,>), typeof(RemoveFromRelationshipProcessor<,>)); + + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddSingleton(); } } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiModelMetadataProvider.cs b/src/JsonApiDotNetCore/Configuration/JsonApiModelMetadataProvider.cs index 9ffe2f7641..5e1941a165 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiModelMetadataProvider.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiModelMetadataProvider.cs @@ -31,12 +31,10 @@ public JsonApiModelMetadataProvider(ICompositeMetadataDetailsProvider detailsPro /// protected override ModelMetadata CreateModelMetadata(DefaultMetadataDetails entry) { - var metadata = (DefaultModelMetadata)base.CreateModelMetadata(entry); + ArgumentNullException.ThrowIfNull(entry); - if (metadata.ValidationMetadata.IsRequired == true) - { - metadata.ValidationMetadata.PropertyValidationFilter = _jsonApiValidationFilter; - } + var metadata = (DefaultModelMetadata)base.CreateModelMetadata(entry); + metadata.ValidationMetadata.PropertyValidationFilter = _jsonApiValidationFilter; return metadata; } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index eda6374acf..182c51431b 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -2,17 +2,19 @@ using System.Text.Encodings.Web; using System.Text.Json; using JetBrains.Annotations; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.JsonConverters; namespace JsonApiDotNetCore.Configuration; -/// +/// [PublicAPI] public sealed class JsonApiOptions : IJsonApiOptions { - private Lazy _lazySerializerWriteOptions; - private Lazy _lazySerializerReadOptions; + private static readonly IReadOnlySet EmptyExtensionSet = new HashSet().AsReadOnly(); + private readonly Lazy _lazySerializerWriteOptions; + private readonly Lazy _lazySerializerReadOptions; /// JsonSerializerOptions IJsonApiOptions.SerializerReadOptions => _lazySerializerReadOptions.Value; @@ -26,6 +28,12 @@ public sealed class JsonApiOptions : IJsonApiOptions /// public AttrCapabilities DefaultAttrCapabilities { get; set; } = AttrCapabilities.All; + /// + public HasOneCapabilities DefaultHasOneCapabilities { get; set; } = HasOneCapabilities.All; + + /// + public HasManyCapabilities DefaultHasManyCapabilities { get; set; } = HasManyCapabilities.All; + /// public bool IncludeJsonApiVersion { get; set; } @@ -63,7 +71,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; } @@ -83,6 +99,9 @@ public sealed class JsonApiOptions : IJsonApiOptions /// public IsolationLevel? TransactionIsolationLevel { get; set; } + /// + public IReadOnlySet Extensions { get; set; } = EmptyExtensionSet; + /// public JsonSerializerOptions SerializerOptions { get; } = new() { @@ -102,15 +121,10 @@ public sealed class JsonApiOptions : IJsonApiOptions } }; - static JsonApiOptions() - { - // Bug workaround for https://github.com/dotnet/efcore/issues/27436 - AppContext.SetSwitch("Microsoft.EntityFrameworkCore.Issue26779", true); - } - public JsonApiOptions() { - _lazySerializerReadOptions = new Lazy(() => new JsonSerializerOptions(SerializerOptions), LazyThreadSafetyMode.PublicationOnly); + _lazySerializerReadOptions = + new Lazy(() => new JsonSerializerOptions(SerializerOptions), LazyThreadSafetyMode.ExecutionAndPublication); _lazySerializerWriteOptions = new Lazy(() => new JsonSerializerOptions(SerializerOptions) { @@ -119,6 +133,29 @@ public JsonApiOptions() new WriteOnlyDocumentConverter(), new WriteOnlyRelationshipObjectConverter() } - }, LazyThreadSafetyMode.PublicationOnly); + }, LazyThreadSafetyMode.ExecutionAndPublication); + } + + /// + /// Adds the specified JSON:API extensions to the existing set. + /// + /// + /// The JSON:API extensions to add. + /// + public void IncludeExtensions(params JsonApiMediaTypeExtension[] extensionsToAdd) + { + ArgumentNullException.ThrowIfNull(extensionsToAdd); + + if (!Extensions.IsSupersetOf(extensionsToAdd)) + { + var extensions = new HashSet(Extensions); + + foreach (JsonApiMediaTypeExtension extension in extensionsToAdd) + { + extensions.Add(extension); + } + + Extensions = extensions.AsReadOnly(); + } } } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs b/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs index 6b193bdc6f..30b850e8b8 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs @@ -1,6 +1,7 @@ using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; using Microsoft.Extensions.DependencyInjection; @@ -15,7 +16,7 @@ internal sealed class JsonApiValidationFilter : IPropertyValidationFilter public JsonApiValidationFilter(IHttpContextAccessor httpContextAccessor) { - ArgumentGuard.NotNull(httpContextAccessor, nameof(httpContextAccessor)); + ArgumentNullException.ThrowIfNull(httpContextAccessor); _httpContextAccessor = httpContextAccessor; } @@ -23,15 +24,13 @@ public JsonApiValidationFilter(IHttpContextAccessor httpContextAccessor) /// public bool ShouldValidateEntry(ValidationEntry entry, ValidationEntry parentEntry) { - IServiceProvider serviceProvider = GetScopedServiceProvider(); - - var request = serviceProvider.GetRequiredService(); - - if (IsId(entry.Key)) + if (entry.Metadata.MetadataKind == ModelMetadataKind.Type || IsId(entry.Key)) { return true; } + IServiceProvider serviceProvider = GetScopedServiceProvider(); + var request = serviceProvider.GetRequiredService(); bool isTopResourceInPrimaryRequest = string.IsNullOrEmpty(parentEntry.Key) && IsAtPrimaryEndpoint(request); if (!isTopResourceInPrimaryRequest) diff --git a/src/JsonApiDotNetCore/Configuration/PageNumber.cs b/src/JsonApiDotNetCore/Configuration/PageNumber.cs index a2d4c0cba0..f4af725a3d 100644 --- a/src/JsonApiDotNetCore/Configuration/PageNumber.cs +++ b/src/JsonApiDotNetCore/Configuration/PageNumber.cs @@ -11,17 +11,14 @@ public sealed class PageNumber : IEquatable public PageNumber(int oneBasedValue) { - if (oneBasedValue < 1) - { - throw new ArgumentOutOfRangeException(nameof(oneBasedValue)); - } + ArgumentOutOfRangeException.ThrowIfLessThan(oneBasedValue, 1); OneBasedValue = oneBasedValue; } public bool Equals(PageNumber? other) { - if (ReferenceEquals(null, other)) + if (other is null) { return false; } diff --git a/src/JsonApiDotNetCore/Configuration/PageSize.cs b/src/JsonApiDotNetCore/Configuration/PageSize.cs index 7f926f519e..4581992597 100644 --- a/src/JsonApiDotNetCore/Configuration/PageSize.cs +++ b/src/JsonApiDotNetCore/Configuration/PageSize.cs @@ -9,17 +9,14 @@ public sealed class PageSize : IEquatable public PageSize(int value) { - if (value < 1) - { - throw new ArgumentOutOfRangeException(nameof(value)); - } + ArgumentOutOfRangeException.ThrowIfLessThan(value, 1); Value = value; } public bool Equals(PageSize? other) { - if (ReferenceEquals(null, other)) + if (other is null) { return false; } diff --git a/src/JsonApiDotNetCore/Configuration/ResourceDescriptor.cs b/src/JsonApiDotNetCore/Configuration/ResourceDescriptor.cs index 8747cdd18f..9c0ce2cf30 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceDescriptor.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceDescriptor.cs @@ -7,8 +7,8 @@ internal sealed class ResourceDescriptor public ResourceDescriptor(Type resourceClrType, Type idClrType) { - ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); - ArgumentGuard.NotNull(idClrType, nameof(idClrType)); + ArgumentNullException.ThrowIfNull(resourceClrType); + ArgumentNullException.ThrowIfNull(idClrType); ResourceClrType = resourceClrType; IdClrType = idClrType; diff --git a/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs b/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs index e73b48ee3d..78d83425ce 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs @@ -8,21 +8,18 @@ namespace JsonApiDotNetCore.Configuration; internal sealed class ResourceDescriptorAssemblyCache { private readonly TypeLocator _typeLocator = new(); - private readonly Dictionary?> _resourceDescriptorsPerAssembly = new(); + private readonly Dictionary _resourceDescriptorsPerAssembly = []; public void RegisterAssembly(Assembly assembly) { - if (!_resourceDescriptorsPerAssembly.ContainsKey(assembly)) - { - _resourceDescriptorsPerAssembly[assembly] = null; - } + _resourceDescriptorsPerAssembly.TryAdd(assembly, null); } public IReadOnlyCollection GetResourceDescriptors() { EnsureAssembliesScanned(); - return _resourceDescriptorsPerAssembly.SelectMany(pair => pair.Value!).ToArray(); + return _resourceDescriptorsPerAssembly.SelectMany(pair => pair.Value!).ToArray().AsReadOnly(); } public IReadOnlyCollection GetAssemblies() diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs index d693fa2c3c..a4ffd082d3 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs @@ -1,3 +1,4 @@ +using System.Collections.ObjectModel; using System.Linq.Expressions; using System.Reflection; using JetBrains.Annotations; @@ -6,19 +7,19 @@ namespace JsonApiDotNetCore.Configuration; -/// +/// [PublicAPI] public sealed class ResourceGraph : IResourceGraph { private static readonly Type? ProxyTargetAccessorType = Type.GetType("Castle.DynamicProxy.IProxyTargetAccessor, Castle.Core"); private readonly IReadOnlySet _resourceTypeSet; - private readonly Dictionary _resourceTypesByClrType = new(); - private readonly Dictionary _resourceTypesByPublicName = new(); + private readonly Dictionary _resourceTypesByClrType = []; + private readonly Dictionary _resourceTypesByPublicName = []; public ResourceGraph(IReadOnlySet resourceTypeSet) { - ArgumentGuard.NotNull(resourceTypeSet, nameof(resourceTypeSet)); + ArgumentNullException.ThrowIfNull(resourceTypeSet); _resourceTypeSet = resourceTypeSet; @@ -51,9 +52,9 @@ public ResourceType GetResourceType(string publicName) /// public ResourceType? FindResourceType(string publicName) { - ArgumentGuard.NotNull(publicName, nameof(publicName)); + ArgumentNullException.ThrowIfNull(publicName); - return _resourceTypesByPublicName.TryGetValue(publicName, out ResourceType? resourceType) ? resourceType : null; + return _resourceTypesByPublicName.GetValueOrDefault(publicName); } /// @@ -72,10 +73,10 @@ public ResourceType GetResourceType(Type resourceClrType) /// public ResourceType? FindResourceType(Type resourceClrType) { - ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); + ArgumentNullException.ThrowIfNull(resourceClrType); Type typeToFind = IsLazyLoadingProxyForResourceType(resourceClrType) ? resourceClrType.BaseType! : resourceClrType; - return _resourceTypesByClrType.TryGetValue(typeToFind, out ResourceType? resourceType) ? resourceType : null; + return _resourceTypesByClrType.GetValueOrDefault(typeToFind); } private bool IsLazyLoadingProxyForResourceType(Type resourceClrType) @@ -94,7 +95,7 @@ public ResourceType GetResourceType() public IReadOnlyCollection GetFields(Expression> selector) where TResource : class, IIdentifiable { - ArgumentGuard.NotNull(selector, nameof(selector)); + ArgumentNullException.ThrowIfNull(selector); return FilterFields(selector); } @@ -103,7 +104,7 @@ public IReadOnlyCollection GetFields(Expressi public IReadOnlyCollection GetAttributes(Expression> selector) where TResource : class, IIdentifiable { - ArgumentGuard.NotNull(selector, nameof(selector)); + ArgumentNullException.ThrowIfNull(selector); return FilterFields(selector); } @@ -112,17 +113,17 @@ public IReadOnlyCollection GetAttributes(Expression GetRelationships(Expression> selector) where TResource : class, IIdentifiable { - ArgumentGuard.NotNull(selector, nameof(selector)); + ArgumentNullException.ThrowIfNull(selector); return FilterFields(selector); } - private IReadOnlyCollection FilterFields(Expression> selector) + private ReadOnlyCollection FilterFields(Expression> selector) where TResource : class, IIdentifiable where TField : ResourceFieldAttribute { IReadOnlyCollection source = GetFieldsOfType(); - var matches = new List(); + List matches = []; foreach (string memberName in ToMemberNames(selector)) { @@ -130,13 +131,13 @@ private IReadOnlyCollection FilterFields(Expression GetFieldsOfType() @@ -178,8 +179,9 @@ private IEnumerable ToMemberNames(Expression article.Title' or 'article => new { article.Title, article.PageCount }'."); + throw new ArgumentException( + $"The expression '{selector}' should select a single property or select multiple properties into an anonymous type. " + + "For example: 'article => article.Title' or 'article => new { article.Title, article.PageCount }'.", nameof(selector)); } } diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs index e07318c7f7..8ab2120d92 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs @@ -1,3 +1,4 @@ +using System.Collections.ObjectModel; using System.Reflection; using JetBrains.Annotations; using JsonApiDotNetCore.Errors; @@ -13,17 +14,17 @@ namespace JsonApiDotNetCore.Configuration; /// Builds and configures the . /// [PublicAPI] -public class ResourceGraphBuilder +public partial class ResourceGraphBuilder { private readonly IJsonApiOptions _options; private readonly ILogger _logger; - private readonly Dictionary _resourceTypesByClrType = new(); + private readonly Dictionary _resourceTypesByClrType = []; private readonly TypeLocator _typeLocator = new(); public ResourceGraphBuilder(IJsonApiOptions options, ILoggerFactory loggerFactory) { - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(loggerFactory); _options = options; _logger = loggerFactory.CreateLogger(); @@ -34,11 +35,11 @@ public ResourceGraphBuilder(IJsonApiOptions options, ILoggerFactory loggerFactor /// public IResourceGraph Build() { - HashSet resourceTypes = _resourceTypesByClrType.Values.ToHashSet(); + IReadOnlySet resourceTypes = _resourceTypesByClrType.Values.ToHashSet().AsReadOnly(); - if (!resourceTypes.Any()) + if (resourceTypes.Count == 0) { - _logger.LogWarning("The resource graph is empty."); + LogResourceGraphIsEmpty(); } var resourceGraph = new ResourceGraph(resourceTypes); @@ -71,8 +72,8 @@ private static void SetRelationshipTypes(ResourceGraph resourceGraph) if (rightType == null) { - throw new InvalidConfigurationException($"Resource type '{relationship.LeftType.ClrType}' depends on " + - $"'{rightClrType}', which was not added to the resource graph."); + throw new InvalidConfigurationException( + $"Resource type '{relationship.LeftType.ClrType}' depends on '{rightClrType}', which was not added to the resource graph."); } relationship.RightType = rightType; @@ -81,7 +82,7 @@ private static void SetRelationshipTypes(ResourceGraph resourceGraph) private static void SetDirectlyDerivedTypes(ResourceGraph resourceGraph) { - Dictionary> directlyDerivedTypesPerBaseType = new(); + Dictionary> directlyDerivedTypesPerBaseType = []; foreach (ResourceType resourceType in resourceGraph.GetResourceTypes()) { @@ -91,18 +92,22 @@ private static void SetDirectlyDerivedTypes(ResourceGraph resourceGraph) { resourceType.BaseType = baseType; - if (!directlyDerivedTypesPerBaseType.ContainsKey(baseType)) + if (!directlyDerivedTypesPerBaseType.TryGetValue(baseType, out HashSet? directlyDerivedTypes)) { - directlyDerivedTypesPerBaseType[baseType] = new HashSet(); + directlyDerivedTypes = []; + directlyDerivedTypesPerBaseType[baseType] = directlyDerivedTypes; } - directlyDerivedTypesPerBaseType[baseType].Add(resourceType); + directlyDerivedTypes.Add(resourceType); } } foreach ((ResourceType baseType, HashSet directlyDerivedTypes) in directlyDerivedTypesPerBaseType) { - baseType.DirectlyDerivedTypes = directlyDerivedTypes; + if (directlyDerivedTypes.Count > 0) + { + baseType.DirectlyDerivedTypes = directlyDerivedTypes.AsReadOnly(); + } } } @@ -124,8 +129,8 @@ private static void ValidateAttributesInDerivedType(ResourceType resourceType) { if (resourceType.FindAttributeByPublicName(attribute.PublicName) == null) { - throw new InvalidConfigurationException($"Attribute '{attribute.PublicName}' from base type " + - $"'{resourceType.BaseType.ClrType}' does not exist in derived type '{resourceType.ClrType}'."); + throw new InvalidConfigurationException( + $"Attribute '{attribute.PublicName}' from base type '{resourceType.BaseType.ClrType}' does not exist in derived type '{resourceType.ClrType}'."); } } } @@ -136,15 +141,15 @@ private static void ValidateRelationshipsInDerivedType(ResourceType resourceType { if (resourceType.FindRelationshipByPublicName(relationship.PublicName) == null) { - throw new InvalidConfigurationException($"Relationship '{relationship.PublicName}' from base type " + - $"'{resourceType.BaseType.ClrType}' does not exist in derived type '{resourceType.ClrType}'."); + throw new InvalidConfigurationException( + $"Relationship '{relationship.PublicName}' from base type '{resourceType.BaseType.ClrType}' does not exist in derived type '{resourceType.ClrType}'."); } } } public ResourceGraphBuilder Add(DbContext dbContext) { - ArgumentGuard.NotNull(dbContext, nameof(dbContext)); + ArgumentNullException.ThrowIfNull(dbContext); foreach (IEntityType entityType in dbContext.Model.GetEntityTypes()) { @@ -159,7 +164,7 @@ public ResourceGraphBuilder Add(DbContext dbContext) private static bool IsImplicitManyToManyJoinEntity(IEntityType entityType) { - return entityType.IsPropertyBag && entityType.HasSharedClrType; + return entityType is { IsPropertyBag: true, HasSharedClrType: true }; } /// @@ -200,7 +205,7 @@ public ResourceGraphBuilder Add(string? publicName = null) public ResourceGraphBuilder Add(Type resourceClrType, Type? idClrType = null, string? publicName = null) #pragma warning restore AV1553 // Do not use optional parameters with default value null for strings, collections or tasks { - ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); + ArgumentNullException.ThrowIfNull(resourceClrType); if (_resourceTypesByClrType.ContainsKey(resourceClrType)) { @@ -227,8 +232,7 @@ public ResourceGraphBuilder Add(Type resourceClrType, Type? idClrType = null, st { if (resourceClrType.GetCustomAttribute() == null) { - _logger.LogWarning( - $"Skipping: Type '{resourceClrType}' does not implement '{nameof(IIdentifiable)}'. Add [NoResource] to suppress this warning."); + LogResourceTypeDoesNotImplementInterface(resourceClrType, nameof(IIdentifiable)); } } @@ -237,21 +241,29 @@ public ResourceGraphBuilder Add(Type resourceClrType, Type? idClrType = null, st private ResourceType CreateResourceType(string publicName, Type resourceClrType, Type idClrType) { - IReadOnlyCollection attributes = GetAttributes(resourceClrType); - IReadOnlyCollection relationships = GetRelationships(resourceClrType); - IReadOnlyCollection eagerLoads = GetEagerLoads(resourceClrType); + ClientIdGenerationMode? clientIdGeneration = GetClientIdGeneration(resourceClrType); + + Dictionary.ValueCollection attributes = GetAttributes(resourceClrType); + Dictionary.ValueCollection relationships = GetRelationships(resourceClrType); + ReadOnlyCollection eagerLoads = GetEagerLoads(resourceClrType); AssertNoDuplicatePublicName(attributes, relationships); 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 IReadOnlyCollection GetAttributes(Type resourceClrType) + private ClientIdGenerationMode? GetClientIdGeneration(Type resourceClrType) + { + var resourceAttribute = resourceClrType.GetCustomAttribute(true); + return resourceAttribute?.NullableClientIdGeneration; + } + + private Dictionary.ValueCollection GetAttributes(Type resourceClrType) { var attributesByName = new Dictionary(); @@ -288,13 +300,13 @@ private IReadOnlyCollection GetAttributes(Type resourceClrType) if (attributesByName.Count < 2) { - _logger.LogWarning($"Type '{resourceClrType}' does not contain any attributes."); + LogResourceTypeHasNoAttributes(resourceClrType); } return attributesByName.Values; } - private IReadOnlyCollection GetRelationships(Type resourceClrType) + private Dictionary.ValueCollection GetRelationships(Type resourceClrType) { var relationshipsByName = new Dictionary(); PropertyInfo[] properties = resourceClrType.GetProperties(); @@ -307,6 +319,7 @@ private IReadOnlyCollection GetRelationships(Type resourc { relationship.Property = property; SetPublicName(relationship, property); + SetRelationshipCapabilities(relationship); IncludeField(relationshipsByName, relationship); } @@ -317,15 +330,61 @@ private IReadOnlyCollection GetRelationships(Type resourc private void SetPublicName(ResourceFieldAttribute field, PropertyInfo property) { - // ReSharper disable once ConstantNullCoalescingCondition + // ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract field.PublicName ??= FormatPropertyName(property); } - private IReadOnlyCollection GetEagerLoads(Type resourceClrType, int recursionDepth = 0) + private void SetRelationshipCapabilities(RelationshipAttribute relationship) + { +#pragma warning disable CS0618 // Type or member is obsolete + bool canInclude = relationship.CanInclude; +#pragma warning restore CS0618 // Type or member is obsolete + + if (relationship is HasOneAttribute hasOneRelationship) + { + SetHasOneRelationshipCapabilities(hasOneRelationship, canInclude); + } + else if (relationship is HasManyAttribute hasManyRelationship) + { + SetHasManyRelationshipCapabilities(hasManyRelationship, canInclude); + } + } + + private void SetHasOneRelationshipCapabilities(HasOneAttribute hasOneRelationship, bool canInclude) + { + if (!hasOneRelationship.HasExplicitCapabilities) + { + hasOneRelationship.Capabilities = _options.DefaultHasOneCapabilities; + } + + if (hasOneRelationship.HasExplicitCanInclude) + { + hasOneRelationship.Capabilities = canInclude + ? hasOneRelationship.Capabilities | HasOneCapabilities.AllowInclude + : hasOneRelationship.Capabilities & ~HasOneCapabilities.AllowInclude; + } + } + + private void SetHasManyRelationshipCapabilities(HasManyAttribute hasManyRelationship, bool canInclude) + { + if (!hasManyRelationship.HasExplicitCapabilities) + { + hasManyRelationship.Capabilities = _options.DefaultHasManyCapabilities; + } + + if (hasManyRelationship.HasExplicitCanInclude) + { + hasManyRelationship.Capabilities = canInclude + ? hasManyRelationship.Capabilities | HasManyCapabilities.AllowInclude + : hasManyRelationship.Capabilities & ~HasManyCapabilities.AllowInclude; + } + } + + private ReadOnlyCollection GetEagerLoads(Type resourceClrType, int recursionDepth = 0) { AssertNoInfiniteRecursion(recursionDepth); - var attributes = new List(); + List eagerLoads = []; PropertyInfo[] properties = resourceClrType.GetProperties(); foreach (PropertyInfo property in properties) @@ -337,14 +396,14 @@ private IReadOnlyCollection GetEagerLoads(Type resourceClrTy continue; } - Type innerType = TypeOrElementType(property.PropertyType); - eagerLoad.Children = GetEagerLoads(innerType, recursionDepth + 1); + Type rightType = CollectionConverter.Instance.FindCollectionElementType(property.PropertyType) ?? property.PropertyType; + eagerLoad.Children = GetEagerLoads(rightType, recursionDepth + 1); eagerLoad.Property = property; - attributes.Add(eagerLoad); + eagerLoads.Add(eagerLoad); } - return attributes; + return eagerLoads.AsReadOnly(); } private static void IncludeField(Dictionary fieldsByName, TField field) @@ -396,18 +455,10 @@ private static void AssertNoInfiniteRecursion(int recursionDepth) { if (recursionDepth >= 500) { - throw new InvalidOperationException("Infinite recursion detected in eager-load chain."); + throw new InvalidConfigurationException("Infinite recursion detected in eager-load chain."); } } - private Type TypeOrElementType(Type type) - { - Type[] interfaces = type.GetInterfaces().Where(@interface => @interface.IsGenericType && @interface.GetGenericTypeDefinition() == typeof(IEnumerable<>)) - .ToArray(); - - return interfaces.Length == 1 ? interfaces.Single().GenericTypeArguments[0] : type; - } - private string FormatResourceName(Type resourceClrType) { var formatter = new ResourceNameFormatter(_options.SerializerOptions.PropertyNamingPolicy); @@ -420,4 +471,14 @@ private string FormatPropertyName(PropertyInfo resourceProperty) ? resourceProperty.Name : _options.SerializerOptions.PropertyNamingPolicy.ConvertName(resourceProperty.Name); } + + [LoggerMessage(Level = LogLevel.Warning, Message = "The resource graph is empty.")] + private partial void LogResourceGraphIsEmpty(); + + [LoggerMessage(Level = LogLevel.Warning, + Message = "Skipping: Type '{ResourceType}' does not implement '{InterfaceType}'. Add [NoResource] to suppress this warning.")] + private partial void LogResourceTypeDoesNotImplementInterface(Type resourceType, string interfaceType); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Type '{ResourceType}' does not contain any attributes.")] + private partial void LogResourceTypeHasNoAttributes(Type resourceType); } diff --git a/src/JsonApiDotNetCore/Configuration/ResourceNameFormatter.cs b/src/JsonApiDotNetCore/Configuration/ResourceNameFormatter.cs index 82a54ff010..0531c25ca9 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceNameFormatter.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceNameFormatter.cs @@ -5,21 +5,16 @@ namespace JsonApiDotNetCore.Configuration; -internal sealed class ResourceNameFormatter +internal sealed class ResourceNameFormatter(JsonNamingPolicy? namingPolicy) { - private readonly JsonNamingPolicy? _namingPolicy; - - public ResourceNameFormatter(JsonNamingPolicy? namingPolicy) - { - _namingPolicy = namingPolicy; - } + private readonly JsonNamingPolicy? _namingPolicy = namingPolicy; /// /// Gets the publicly exposed resource name by applying the configured naming convention on the pluralized CLR type name. /// public string FormatResourceName(Type resourceClrType) { - ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); + ArgumentNullException.ThrowIfNull(resourceClrType); var resourceAttribute = resourceClrType.GetCustomAttribute(true); diff --git a/src/JsonApiDotNetCore/Configuration/ResourcesAssemblyScanner.cs b/src/JsonApiDotNetCore/Configuration/ResourcesAssemblyScanner.cs new file mode 100644 index 0000000000..f2dcf827d3 --- /dev/null +++ b/src/JsonApiDotNetCore/Configuration/ResourcesAssemblyScanner.cs @@ -0,0 +1,29 @@ +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Configuration; + +/// +/// Scans assemblies for types that implement and adds them to the resource graph. +/// +internal sealed class ResourcesAssemblyScanner +{ + private readonly ResourceDescriptorAssemblyCache _assemblyCache; + private readonly ResourceGraphBuilder _resourceGraphBuilder; + + public ResourcesAssemblyScanner(ResourceDescriptorAssemblyCache assemblyCache, ResourceGraphBuilder resourceGraphBuilder) + { + ArgumentNullException.ThrowIfNull(assemblyCache); + ArgumentNullException.ThrowIfNull(resourceGraphBuilder); + + _assemblyCache = assemblyCache; + _resourceGraphBuilder = resourceGraphBuilder; + } + + public void DiscoverResources() + { + foreach (ResourceDescriptor resourceDescriptor in _assemblyCache.GetResourceDescriptors()) + { + _resourceGraphBuilder.Add(resourceDescriptor.ResourceClrType, resourceDescriptor.IdClrType); + } + } +} diff --git a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs index 8e5d15a7c4..65bf465ce3 100644 --- a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs @@ -6,8 +6,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -#pragma warning disable AV1130 // Return type in method signature should be an interface to an unchangeable collection - namespace JsonApiDotNetCore.Configuration; [PublicAPI] @@ -24,7 +22,7 @@ public static IServiceCollection AddJsonApi(this IServiceCollection services, Ac ICollection? dbContextTypes = null) #pragma warning restore AV1553 // Do not use optional parameters with default value null for strings, collections or tasks { - ArgumentGuard.NotNull(services, nameof(services)); + ArgumentNullException.ThrowIfNull(services); SetupApplicationBuilder(services, options, discovery, resources, mvcBuilder, dbContextTypes ?? Array.Empty()); @@ -38,14 +36,14 @@ public static IServiceCollection AddJsonApi(this IServiceCollection Action? discovery = null, Action? resources = null, IMvcCoreBuilder? mvcBuilder = null) where TDbContext : DbContext { - return AddJsonApi(services, options, discovery, resources, mvcBuilder, typeof(TDbContext).AsArray()); + return AddJsonApi(services, options, discovery, resources, mvcBuilder, [typeof(TDbContext)]); } private static void SetupApplicationBuilder(IServiceCollection services, Action? configureOptions, Action? configureAutoDiscovery, Action? configureResources, IMvcCoreBuilder? mvcBuilder, ICollection dbContextTypes) { - using var applicationBuilder = new JsonApiApplicationBuilder(services, mvcBuilder ?? services.AddMvcCore()); + var applicationBuilder = new JsonApiApplicationBuilder(services, mvcBuilder ?? services.AddMvcCore()); applicationBuilder.ConfigureJsonApiOptions(configureOptions); applicationBuilder.ConfigureAutoDiscovery(configureAutoDiscovery); @@ -61,9 +59,9 @@ private static void SetupApplicationBuilder(IServiceCollection services, Action< /// public static IServiceCollection AddResourceService(this IServiceCollection services) { - ArgumentGuard.NotNull(services, nameof(services)); + ArgumentNullException.ThrowIfNull(services); - RegisterTypeForUnboundInterfaces(services, typeof(TService), ServiceDiscoveryFacade.ServiceUnboundInterfaces); + RegisterTypeForUnboundInterfaces(services, typeof(TService), InjectablesAssemblyScanner.ServiceUnboundInterfaces); return services; } @@ -74,9 +72,9 @@ public static IServiceCollection AddResourceService(this IServiceColle /// public static IServiceCollection AddResourceRepository(this IServiceCollection services) { - ArgumentGuard.NotNull(services, nameof(services)); + ArgumentNullException.ThrowIfNull(services); - RegisterTypeForUnboundInterfaces(services, typeof(TRepository), ServiceDiscoveryFacade.RepositoryUnboundInterfaces); + RegisterTypeForUnboundInterfaces(services, typeof(TRepository), InjectablesAssemblyScanner.RepositoryUnboundInterfaces); return services; } @@ -87,9 +85,9 @@ public static IServiceCollection AddResourceRepository(this IServic /// public static IServiceCollection AddResourceDefinition(this IServiceCollection services) { - ArgumentGuard.NotNull(services, nameof(services)); + ArgumentNullException.ThrowIfNull(services); - RegisterTypeForUnboundInterfaces(services, typeof(TResourceDefinition), ServiceDiscoveryFacade.ResourceDefinitionUnboundInterfaces); + RegisterTypeForUnboundInterfaces(services, typeof(TResourceDefinition), InjectablesAssemblyScanner.ResourceDefinitionUnboundInterfaces); return services; } diff --git a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs index 7498391afd..d28b8381c6 100644 --- a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs @@ -1,68 +1,25 @@ using System.Reflection; using JetBrains.Annotations; -using JsonApiDotNetCore.Repositories; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Services; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Configuration; /// -/// Scans for types like resources, services, repositories and resource definitions in an assembly and registers them to the IoC container. +/// Provides auto-discovery by scanning assemblies for resources and related injectables. /// [PublicAPI] public sealed class ServiceDiscoveryFacade { - internal static readonly HashSet ServiceUnboundInterfaces = new() - { - typeof(IResourceService<,>), - typeof(IResourceCommandService<,>), - typeof(IResourceQueryService<,>), - typeof(IGetAllService<,>), - typeof(IGetByIdService<,>), - typeof(IGetSecondaryService<,>), - typeof(IGetRelationshipService<,>), - typeof(ICreateService<,>), - typeof(IAddToRelationshipService<,>), - typeof(IUpdateService<,>), - typeof(ISetRelationshipService<,>), - typeof(IDeleteService<,>), - typeof(IRemoveFromRelationshipService<,>) - }; - - internal static readonly HashSet RepositoryUnboundInterfaces = new() - { - typeof(IResourceRepository<,>), - typeof(IResourceWriteRepository<,>), - typeof(IResourceReadRepository<,>) - }; - - internal static readonly HashSet ResourceDefinitionUnboundInterfaces = new() - { - typeof(IResourceDefinition<,>) - }; + private readonly ResourceDescriptorAssemblyCache _assemblyCache; - private readonly ILogger _logger; - private readonly IServiceCollection _services; - private readonly ResourceGraphBuilder _resourceGraphBuilder; - private readonly ResourceDescriptorAssemblyCache _assemblyCache = new(); - private readonly TypeLocator _typeLocator = new(); - - public ServiceDiscoveryFacade(IServiceCollection services, ResourceGraphBuilder resourceGraphBuilder, ILoggerFactory loggerFactory) + internal ServiceDiscoveryFacade(ResourceDescriptorAssemblyCache assemblyCache) { - ArgumentGuard.NotNull(services, nameof(services)); - ArgumentGuard.NotNull(resourceGraphBuilder, nameof(resourceGraphBuilder)); - ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); + ArgumentNullException.ThrowIfNull(assemblyCache); - _logger = loggerFactory.CreateLogger(); - _services = services; - _resourceGraphBuilder = resourceGraphBuilder; + _assemblyCache = assemblyCache; } /// - /// Mark the calling assembly for scanning of resources and injectables. + /// Includes the calling assembly for auto-discovery of resources and related injectables. /// public ServiceDiscoveryFacade AddCurrentAssembly() { @@ -70,100 +27,13 @@ public ServiceDiscoveryFacade AddCurrentAssembly() } /// - /// Mark the specified assembly for scanning of resources and injectables. + /// Includes the specified assembly for auto-discovery of resources and related injectables. /// public ServiceDiscoveryFacade AddAssembly(Assembly assembly) { - ArgumentGuard.NotNull(assembly, nameof(assembly)); + ArgumentNullException.ThrowIfNull(assembly); _assemblyCache.RegisterAssembly(assembly); - _logger.LogDebug($"Registering assembly '{assembly.FullName}' for discovery of resources and injectables."); - return this; } - - internal void DiscoverResources() - { - foreach (ResourceDescriptor resourceDescriptor in _assemblyCache.GetResourceDescriptors()) - { - AddResource(resourceDescriptor); - } - } - - internal void DiscoverInjectables() - { - IReadOnlyCollection descriptors = _assemblyCache.GetResourceDescriptors(); - IReadOnlyCollection assemblies = _assemblyCache.GetAssemblies(); - - foreach (Assembly assembly in assemblies) - { - AddDbContextResolvers(assembly); - AddInjectables(descriptors, assembly); - } - } - - private void AddInjectables(IReadOnlyCollection resourceDescriptors, Assembly assembly) - { - foreach (ResourceDescriptor resourceDescriptor in resourceDescriptors) - { - AddServices(assembly, resourceDescriptor); - AddRepositories(assembly, resourceDescriptor); - AddResourceDefinitions(assembly, resourceDescriptor); - } - } - - private void AddDbContextResolvers(Assembly assembly) - { - IEnumerable dbContextTypes = _typeLocator.GetDerivedTypes(assembly, typeof(DbContext)); - - foreach (Type dbContextType in dbContextTypes) - { - Type dbContextResolverClosedType = typeof(DbContextResolver<>).MakeGenericType(dbContextType); - _services.AddScoped(typeof(IDbContextResolver), dbContextResolverClosedType); - } - } - - private void AddResource(ResourceDescriptor resourceDescriptor) - { - _resourceGraphBuilder.Add(resourceDescriptor.ResourceClrType, resourceDescriptor.IdClrType); - } - - private void AddServices(Assembly assembly, ResourceDescriptor resourceDescriptor) - { - foreach (Type serviceUnboundInterface in ServiceUnboundInterfaces) - { - RegisterImplementations(assembly, serviceUnboundInterface, resourceDescriptor); - } - } - - private void AddRepositories(Assembly assembly, ResourceDescriptor resourceDescriptor) - { - foreach (Type repositoryUnboundInterface in RepositoryUnboundInterfaces) - { - RegisterImplementations(assembly, repositoryUnboundInterface, resourceDescriptor); - } - } - - private void AddResourceDefinitions(Assembly assembly, ResourceDescriptor resourceDescriptor) - { - foreach (Type resourceDefinitionUnboundInterface in ResourceDefinitionUnboundInterfaces) - { - RegisterImplementations(assembly, resourceDefinitionUnboundInterface, resourceDescriptor); - } - } - - private void RegisterImplementations(Assembly assembly, Type interfaceType, ResourceDescriptor resourceDescriptor) - { - Type[] typeArguments = interfaceType.GetTypeInfo().GenericTypeParameters.Length == 2 - ? ArrayFactory.Create(resourceDescriptor.ResourceClrType, resourceDescriptor.IdClrType) - : ArrayFactory.Create(resourceDescriptor.ResourceClrType); - - (Type implementationType, Type serviceInterface)? result = _typeLocator.GetContainerRegistrationFromAssembly(assembly, interfaceType, typeArguments); - - if (result != null) - { - (Type implementationType, Type serviceInterface) = result.Value; - _services.AddScoped(serviceInterface, implementationType); - } - } } diff --git a/src/JsonApiDotNetCore/Configuration/TypeLocator.cs b/src/JsonApiDotNetCore/Configuration/TypeLocator.cs index 2f004ffdf1..768f94a98d 100644 --- a/src/JsonApiDotNetCore/Configuration/TypeLocator.cs +++ b/src/JsonApiDotNetCore/Configuration/TypeLocator.cs @@ -66,9 +66,9 @@ internal sealed class TypeLocator public (Type implementationType, Type serviceInterface)? GetContainerRegistrationFromAssembly(Assembly assembly, Type unboundInterface, params Type[] interfaceTypeArguments) { - ArgumentGuard.NotNull(assembly, nameof(assembly)); - ArgumentGuard.NotNull(unboundInterface, nameof(unboundInterface)); - ArgumentGuard.NotNull(interfaceTypeArguments, nameof(interfaceTypeArguments)); + ArgumentNullException.ThrowIfNull(assembly); + ArgumentNullException.ThrowIfNull(unboundInterface); + ArgumentNullException.ThrowIfNull(interfaceTypeArguments); if (!unboundInterface.IsInterface || !unboundInterface.IsGenericType || unboundInterface != unboundInterface.GetGenericTypeDefinition()) { @@ -82,14 +82,22 @@ internal sealed class TypeLocator $"instead of {interfaceTypeArguments.Length}.", nameof(interfaceTypeArguments)); } - return assembly.GetTypes().Select(type => GetContainerRegistrationFromType(type, unboundInterface, interfaceTypeArguments)) + // @formatter:wrap_chained_method_calls chop_always + // @formatter:wrap_before_first_method_call true + + return assembly + .GetTypes() + .Select(type => GetContainerRegistrationFromType(type, unboundInterface, interfaceTypeArguments)) .FirstOrDefault(result => result != null); + + // @formatter:wrap_before_first_method_call restore + // @formatter:wrap_chained_method_calls restore } private static (Type implementationType, Type serviceInterface)? GetContainerRegistrationFromType(Type nextType, Type unboundInterface, Type[] interfaceTypeArguments) { - if (!nextType.IsNested && !nextType.IsAbstract && !nextType.IsInterface) + if (nextType is { IsNested: false, IsAbstract: false, IsInterface: false }) { foreach (Type nextConstructedInterface in nextType.GetInterfaces().Where(type => type.IsGenericType)) { @@ -110,33 +118,6 @@ private static (Type implementationType, Type serviceInterface)? GetContainerReg return null; } - /// - /// Scans for types in the specified assembly that derive from the specified unbound generic type. - /// - /// - /// The assembly to search for derived types. - /// - /// - /// The unbound generic type to match against. - /// - /// - /// Generic type arguments to construct . - /// - /// - /// ), typeof(Article), typeof(int)) - /// ]]> - /// - public IReadOnlyCollection GetDerivedTypesForUnboundType(Assembly assembly, Type unboundType, params Type[] typeArguments) - { - ArgumentGuard.NotNull(assembly, nameof(assembly)); - ArgumentGuard.NotNull(unboundType, nameof(unboundType)); - ArgumentGuard.NotNull(typeArguments, nameof(typeArguments)); - - Type closedType = unboundType.MakeGenericType(typeArguments); - return GetDerivedTypes(assembly, closedType).ToArray(); - } - /// /// Gets all derivatives of the specified type. /// @@ -147,14 +128,14 @@ public IReadOnlyCollection GetDerivedTypesForUnboundType(Assembly assembly /// The inherited type. /// /// - /// + /// + /// ]]> /// public IEnumerable GetDerivedTypes(Assembly assembly, Type baseType) { - ArgumentGuard.NotNull(assembly, nameof(assembly)); - ArgumentGuard.NotNull(baseType, nameof(baseType)); + ArgumentNullException.ThrowIfNull(assembly); + ArgumentNullException.ThrowIfNull(baseType); foreach (Type type in assembly.GetTypes()) { diff --git a/src/JsonApiDotNetCore/Controllers/Annotations/DisableQueryStringAttribute.cs b/src/JsonApiDotNetCore/Controllers/Annotations/DisableQueryStringAttribute.cs index 975472ab28..d90d67118e 100644 --- a/src/JsonApiDotNetCore/Controllers/Annotations/DisableQueryStringAttribute.cs +++ b/src/JsonApiDotNetCore/Controllers/Annotations/DisableQueryStringAttribute.cs @@ -27,7 +27,7 @@ public sealed class DisableQueryStringAttribute : Attribute /// public DisableQueryStringAttribute(JsonApiQueryStringParameters parameters) { - var parameterNames = new HashSet(); + HashSet parameterNames = []; foreach (JsonApiQueryStringParameters value in Enum.GetValues()) { @@ -37,7 +37,7 @@ public DisableQueryStringAttribute(JsonApiQueryStringParameters parameters) } } - ParameterNames = parameterNames; + ParameterNames = parameterNames.AsReadOnly(); } /// @@ -46,9 +46,9 @@ public DisableQueryStringAttribute(JsonApiQueryStringParameters parameters) /// public DisableQueryStringAttribute(string parameterNames) { - ArgumentGuard.NotNullNorEmpty(parameterNames, nameof(parameterNames)); + ArgumentException.ThrowIfNullOrEmpty(parameterNames); - ParameterNames = parameterNames.Split(",").ToHashSet(); + ParameterNames = parameterNames.Split(",").ToHashSet().AsReadOnly(); } public bool ContainsParameter(JsonApiQueryStringParameters parameter) diff --git a/src/JsonApiDotNetCore/Controllers/Annotations/DisableRoutingConventionAttribute.cs b/src/JsonApiDotNetCore/Controllers/Annotations/DisableRoutingConventionAttribute.cs index 34e1132789..19df79dc2b 100644 --- a/src/JsonApiDotNetCore/Controllers/Annotations/DisableRoutingConventionAttribute.cs +++ b/src/JsonApiDotNetCore/Controllers/Annotations/DisableRoutingConventionAttribute.cs @@ -11,6 +11,4 @@ namespace JsonApiDotNetCore.Controllers.Annotations; /// ]]> [PublicAPI] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] -public sealed class DisableRoutingConventionAttribute : Attribute -{ -} +public sealed class DisableRoutingConventionAttribute : Attribute; diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index cabe4d49d8..acd6528500 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -1,8 +1,11 @@ +using System.Diagnostics.CodeAnalysis; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -63,9 +66,9 @@ protected BaseJsonApiController(IJsonApiOptions options, IResourceGraph resource IUpdateService? update = null, ISetRelationshipService? setRelationship = null, IDeleteService? delete = null, IRemoveFromRelationshipService? removeFromRelationship = null) { - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(resourceGraph); + ArgumentNullException.ThrowIfNull(loggerFactory); _options = options; _resourceGraph = resourceGraph; @@ -106,7 +109,7 @@ public virtual async Task GetAsync(CancellationToken cancellation /// GET /articles/1 HTTP/1.1 /// ]]> /// - public virtual async Task GetAsync(TId id, CancellationToken cancellationToken) + public virtual async Task GetAsync([DisallowNull] TId id, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -131,7 +134,8 @@ public virtual async Task GetAsync(TId id, CancellationToken canc /// GET /articles/1/revisions HTTP/1.1 /// ]]> /// - public virtual async Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) + public virtual async Task GetSecondaryAsync([DisallowNull] TId id, [PreserveEmptyString] string relationshipName, + CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -139,7 +143,7 @@ public virtual async Task GetSecondaryAsync(TId id, string relati relationshipName }); - ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); + ArgumentNullException.ThrowIfNull(relationshipName); if (_getSecondary == null) { @@ -160,7 +164,8 @@ public virtual async Task GetSecondaryAsync(TId id, string relati /// GET /articles/1/relationships/revisions HTTP/1.1 /// ]]> /// - public virtual async Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) + public virtual async Task GetRelationshipAsync([DisallowNull] TId id, [PreserveEmptyString] string relationshipName, + CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -168,7 +173,7 @@ public virtual async Task GetRelationshipAsync(TId id, string rel relationshipName }); - ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); + ArgumentNullException.ThrowIfNull(relationshipName); if (_getRelationship == null) { @@ -192,7 +197,7 @@ public virtual async Task PostAsync([FromBody] TResource resource resource }); - ArgumentGuard.NotNull(resource, nameof(resource)); + ArgumentNullException.ThrowIfNull(resource); if (_create == null) { @@ -207,17 +212,26 @@ public virtual async Task PostAsync([FromBody] TResource resource TResource? newResource = await _create.CreateAsync(resource, cancellationToken); string resourceId = (newResource ?? resource).StringId!; - string locationUrl = $"{HttpContext.Request.Path}/{resourceId}"; + string locationUrl = GetLocationUrl(resourceId); if (newResource == null) { - HttpContext.Response.Headers["Location"] = locationUrl; + HttpContext.Response.Headers.Location = locationUrl; return NoContent(); } return Created(locationUrl, newResource); } + private string GetLocationUrl(string resourceId) + { + PathString locationPath = HttpContext.Request.Path.Add($"/{resourceId}"); + + return _options.UseRelativeLinks + ? UriHelper.BuildRelative(HttpContext.Request.PathBase, locationPath) + : UriHelper.BuildAbsolute(HttpContext.Request.Scheme, HttpContext.Request.Host, HttpContext.Request.PathBase, locationPath); + } + /// /// Adds resources to a to-many relationship. Example: PostAsync([FromBody] TResource resource /// /// Propagates notification that request handling should be canceled. /// - public virtual async Task PostRelationshipAsync(TId id, string relationshipName, [FromBody] ISet rightResourceIds, - CancellationToken cancellationToken) + public virtual async Task PostRelationshipAsync([DisallowNull] TId id, [PreserveEmptyString] string relationshipName, + [FromBody] ISet rightResourceIds, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -245,8 +259,8 @@ public virtual async Task PostRelationshipAsync(TId id, string re rightResourceIds }); - ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); - ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); + ArgumentNullException.ThrowIfNull(relationshipName); + ArgumentNullException.ThrowIfNull(rightResourceIds); if (_addToRelationship == null) { @@ -264,7 +278,7 @@ public virtual async Task PostRelationshipAsync(TId id, string re /// PATCH /articles/1 HTTP/1.1 /// ]]> /// - public virtual async Task PatchAsync(TId id, [FromBody] TResource resource, CancellationToken cancellationToken) + public virtual async Task PatchAsync([DisallowNull] TId id, [FromBody] TResource resource, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -272,7 +286,7 @@ public virtual async Task PatchAsync(TId id, [FromBody] TResource resource }); - ArgumentGuard.NotNull(resource, nameof(resource)); + ArgumentNullException.ThrowIfNull(resource); if (_update == null) { @@ -310,8 +324,8 @@ public virtual async Task PatchAsync(TId id, [FromBody] TResource /// /// Propagates notification that request handling should be canceled. /// - public virtual async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object? rightValue, - CancellationToken cancellationToken) + public virtual async Task PatchRelationshipAsync([DisallowNull] TId id, [PreserveEmptyString] string relationshipName, + [FromBody] object? rightValue, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -320,7 +334,7 @@ public virtual async Task PatchRelationshipAsync(TId id, string r rightValue }); - ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); + ArgumentNullException.ThrowIfNull(relationshipName); if (_setRelationship == null) { @@ -337,7 +351,7 @@ public virtual async Task PatchRelationshipAsync(TId id, string r /// DELETE /articles/1 HTTP/1.1 /// ]]> /// - public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToken) + public virtual async Task DeleteAsync([DisallowNull] TId id, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -371,8 +385,8 @@ public virtual async Task DeleteAsync(TId id, CancellationToken c /// /// Propagates notification that request handling should be canceled. /// - public virtual async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] ISet rightResourceIds, - CancellationToken cancellationToken) + public virtual async Task DeleteRelationshipAsync([DisallowNull] TId id, [PreserveEmptyString] string relationshipName, + [FromBody] ISet rightResourceIds, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -381,8 +395,8 @@ public virtual async Task DeleteRelationshipAsync(TId id, string rightResourceIds }); - ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); - ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); + ArgumentNullException.ThrowIfNull(relationshipName); + ArgumentNullException.ThrowIfNull(rightResourceIds); if (_removeFromRelationship == null) { diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs index a948d67edc..6c6703c132 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs @@ -1,9 +1,11 @@ +using System.Net; using JetBrains.Annotations; using JsonApiDotNetCore.AtomicOperations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.Logging; @@ -22,23 +24,26 @@ public abstract class BaseJsonApiOperationsController : CoreJsonApiController private readonly IOperationsProcessor _processor; private readonly IJsonApiRequest _request; private readonly ITargetedFields _targetedFields; + private readonly IAtomicOperationFilter _operationFilter; private readonly TraceLogWriter _traceWriter; protected BaseJsonApiOperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields) + IOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields, IAtomicOperationFilter operationFilter) { - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); - ArgumentGuard.NotNull(processor, nameof(processor)); - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(resourceGraph); + ArgumentNullException.ThrowIfNull(loggerFactory); + ArgumentNullException.ThrowIfNull(processor); + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(targetedFields); + ArgumentNullException.ThrowIfNull(operationFilter); _options = options; _resourceGraph = resourceGraph; _processor = processor; _request = request; _targetedFields = targetedFields; + _operationFilter = operationFilter; _traceWriter = new TraceLogWriter(loggerFactory); } @@ -109,7 +114,9 @@ public virtual async Task PostOperationsAsync([FromBody] IList PostOperationsAsync([FromBody] IList result != null) ? Ok(results) : NoContent(); } + protected virtual void ValidateEnabledOperations(IList operations) + { + ArgumentNullException.ThrowIfNull(operations); + + List errors = []; + + for (int operationIndex = 0; operationIndex < operations.Count; operationIndex++) + { + IJsonApiRequest operationRequest = operations[operationIndex].Request; + WriteOperationKind writeOperation = operationRequest.WriteOperation!.Value; + + if (operationRequest.Relationship != null && !_operationFilter.IsEnabled(operationRequest.Relationship.LeftType, writeOperation)) + { + string operationCode = GetOperationCodeText(writeOperation); + + errors.Add(new ErrorObject(HttpStatusCode.Forbidden) + { + Title = "The requested operation is not accessible.", + Detail = $"The '{operationCode}' relationship operation is not accessible for relationship '{operationRequest.Relationship}' " + + $"on resource type '{operationRequest.Relationship.LeftType}'.", + Source = new ErrorSource + { + Pointer = $"/atomic:operations[{operationIndex}]" + } + }); + } + else if (operationRequest.PrimaryResourceType != null && !_operationFilter.IsEnabled(operationRequest.PrimaryResourceType, writeOperation)) + { + string operationCode = GetOperationCodeText(writeOperation); + + errors.Add(new ErrorObject(HttpStatusCode.Forbidden) + { + Title = "The requested operation is not accessible.", + Detail = $"The '{operationCode}' resource operation is not accessible for resource type '{operationRequest.PrimaryResourceType}'.", + Source = new ErrorSource + { + Pointer = $"/atomic:operations[{operationIndex}]" + } + }); + } + } + + if (errors.Count > 0) + { + throw new JsonApiException(errors); + } + } + + private static string GetOperationCodeText(WriteOperationKind writeOperation) + { + AtomicOperationCode operationCode = writeOperation switch + { + WriteOperationKind.CreateResource => AtomicOperationCode.Add, + WriteOperationKind.UpdateResource => AtomicOperationCode.Update, + WriteOperationKind.DeleteResource => AtomicOperationCode.Remove, + WriteOperationKind.AddToRelationship => AtomicOperationCode.Add, + WriteOperationKind.SetRelationship => AtomicOperationCode.Update, + WriteOperationKind.RemoveFromRelationship => AtomicOperationCode.Remove, + _ => throw new NotSupportedException($"Unknown operation kind '{writeOperation}'.") + }; + + return operationCode.ToString().ToLowerInvariant(); + } + protected virtual void ValidateModelState(IList operations) { + ArgumentNullException.ThrowIfNull(operations); + // We must validate the resource inside each operation manually, because they are typed as IIdentifiable. // Instead of validating IIdentifiable we need to validate the resource runtime-type. using IDisposable _ = new RevertRequestStateOnDispose(_request, _targetedFields); int operationIndex = 0; - var requestModelState = new List<(string key, ModelStateEntry? entry)>(); + List<(string key, ModelStateEntry? entry)> requestModelState = []; int maxErrorsRemaining = ModelState.MaxAllowedErrors; foreach (OperationContainer operation in operations) @@ -143,7 +216,7 @@ protected virtual void ValidateModelState(IList operations) operationIndex++; } - if (requestModelState.Any()) + if (requestModelState.Count > 0) { Dictionary modelStateDictionary = requestModelState.ToDictionary(tuple => tuple.key, tuple => tuple.entry); @@ -166,7 +239,8 @@ private int ValidateOperation(OperationContainer operation, int operationIndex, ModelState = { MaxAllowedErrors = maxErrorsRemaining - } + }, + HttpContext = HttpContext }; ObjectValidator.Validate(validationContext, null, string.Empty, operation.Resource); diff --git a/src/JsonApiDotNetCore/Controllers/CoreJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/CoreJsonApiController.cs index 561a443d14..f81ec9d071 100644 --- a/src/JsonApiDotNetCore/Controllers/CoreJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/CoreJsonApiController.cs @@ -1,3 +1,4 @@ +using System.Collections.ObjectModel; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Mvc; @@ -10,7 +11,7 @@ public abstract class CoreJsonApiController : ControllerBase { protected IActionResult Error(ErrorObject error) { - ArgumentGuard.NotNull(error, nameof(error)); + ArgumentNullException.ThrowIfNull(error); return new ObjectResult(error) { @@ -20,17 +21,17 @@ protected IActionResult Error(ErrorObject error) protected IActionResult Error(IEnumerable errors) { - IReadOnlyList? errorList = ToErrorList(errors); - ArgumentGuard.NotNullNorEmpty(errorList, nameof(errors)); + ReadOnlyCollection? errorCollection = ToCollection(errors); + ArgumentGuard.NotNullNorEmpty(errorCollection, nameof(errors)); - return new ObjectResult(errorList) + return new ObjectResult(errorCollection) { - StatusCode = (int)ErrorObject.GetResponseStatusCode(errorList) + StatusCode = (int)ErrorObject.GetResponseStatusCode(errorCollection) }; } - private static IReadOnlyList? ToErrorList(IEnumerable? errors) + private static ReadOnlyCollection? ToCollection(IEnumerable? errors) { - return errors?.ToArray(); + return errors?.ToArray().AsReadOnly(); } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs index 290487cb76..cd58e2d18d 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs @@ -15,16 +15,8 @@ namespace JsonApiDotNetCore.Controllers; /// /// The resource identifier type. /// -public abstract class JsonApiCommandController : JsonApiController - where TResource : class, IIdentifiable -{ - /// - /// Creates an instance from a write-only service. - /// - protected JsonApiCommandController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IResourceCommandService commandService) - : base(options, resourceGraph, loggerFactory, null, null, null, null, commandService, commandService, commandService, commandService, commandService, - commandService) - { - } -} +public abstract class JsonApiCommandController( + IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceCommandService commandService) + : JsonApiController(options, resourceGraph, loggerFactory, null, null, null, null, commandService, commandService, commandService, + commandService, commandService, commandService) + where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs index 091bbee47b..84e4dad3b5 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -1,9 +1,13 @@ +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; +#pragma warning disable format + namespace JsonApiDotNetCore.Controllers; /// @@ -49,7 +53,7 @@ public override async Task GetAsync(CancellationToken cancellatio /// [HttpGet("{id}")] [HttpHead("{id}")] - public override async Task GetAsync(TId id, CancellationToken cancellationToken) + public override async Task GetAsync([Required(AllowEmptyStrings = true)] [DisallowNull] TId id, CancellationToken cancellationToken) { return await base.GetAsync(id, cancellationToken); } @@ -57,7 +61,8 @@ public override async Task GetAsync(TId id, CancellationToken can /// [HttpGet("{id}/{relationshipName}")] [HttpHead("{id}/{relationshipName}")] - public override async Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) + public override async Task GetSecondaryAsync([Required(AllowEmptyStrings = true)] [DisallowNull] TId id, [Required] string relationshipName, + CancellationToken cancellationToken) { return await base.GetSecondaryAsync(id, relationshipName, cancellationToken); } @@ -65,52 +70,56 @@ public override async Task GetSecondaryAsync(TId id, string relat /// [HttpGet("{id}/relationships/{relationshipName}")] [HttpHead("{id}/relationships/{relationshipName}")] - public override async Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) + public override async Task GetRelationshipAsync([Required(AllowEmptyStrings = true)] [DisallowNull] TId id, + [Required] string relationshipName, CancellationToken cancellationToken) { return await base.GetRelationshipAsync(id, relationshipName, cancellationToken); } /// [HttpPost] - public override async Task PostAsync([FromBody] TResource resource, CancellationToken cancellationToken) + public override async Task PostAsync([Required] TResource resource, CancellationToken cancellationToken) { return await base.PostAsync(resource, cancellationToken); } /// [HttpPost("{id}/relationships/{relationshipName}")] - public override async Task PostRelationshipAsync(TId id, string relationshipName, [FromBody] ISet rightResourceIds, - CancellationToken cancellationToken) + public override async Task PostRelationshipAsync([Required(AllowEmptyStrings = true)] [DisallowNull] TId id, + [Required] string relationshipName, [Required] ISet rightResourceIds, CancellationToken cancellationToken) { return await base.PostRelationshipAsync(id, relationshipName, rightResourceIds, cancellationToken); } /// [HttpPatch("{id}")] - public override async Task PatchAsync(TId id, [FromBody] TResource resource, CancellationToken cancellationToken) + public override async Task PatchAsync([Required(AllowEmptyStrings = true)] [DisallowNull] TId id, [Required] TResource resource, + CancellationToken cancellationToken) { return await base.PatchAsync(id, resource, cancellationToken); } /// [HttpPatch("{id}/relationships/{relationshipName}")] - public override async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object? rightValue, - CancellationToken cancellationToken) + // `AllowEmptyStrings = true` in `[Required]` prevents the model binder from producing a validation error on whitespace when TId is string. + // Parameter `[Required] object? rightValue` makes Swashbuckle generate the OpenAPI request body as required. We don't actually validate ModelState, so it doesn't hurt. + public override async Task PatchRelationshipAsync([Required(AllowEmptyStrings = true)] [DisallowNull] TId id, + [Required] string relationshipName, [Required] object? rightValue, CancellationToken cancellationToken) { return await base.PatchRelationshipAsync(id, relationshipName, rightValue, cancellationToken); } /// [HttpDelete("{id}")] - public override async Task DeleteAsync(TId id, CancellationToken cancellationToken) + public override async Task DeleteAsync([Required(AllowEmptyStrings = true)] [DisallowNull] TId id, CancellationToken cancellationToken) { return await base.DeleteAsync(id, cancellationToken); } /// [HttpDelete("{id}/relationships/{relationshipName}")] - public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] ISet rightResourceIds, - CancellationToken cancellationToken) + public override async Task DeleteRelationshipAsync([Required(AllowEmptyStrings = true)] [DisallowNull] TId id, + [Required] string relationshipName, [Required] ISet rightResourceIds, CancellationToken cancellationToken) { return await base.DeleteRelationshipAsync(id, relationshipName, rightResourceIds, cancellationToken); } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs index 452a5eac09..90fec3b9f3 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using JsonApiDotNetCore.AtomicOperations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; @@ -11,17 +12,14 @@ namespace JsonApiDotNetCore.Controllers; /// The base class to derive atomic:operations controllers from. This class delegates all work to but adds /// attributes for routing templates. If you want to provide routing templates yourself, you should derive from BaseJsonApiOperationsController directly. /// -public abstract class JsonApiOperationsController : BaseJsonApiOperationsController +public abstract class JsonApiOperationsController( + IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request, + ITargetedFields targetedFields, IAtomicOperationFilter operationFilter) + : BaseJsonApiOperationsController(options, resourceGraph, loggerFactory, processor, request, targetedFields, operationFilter) { - protected JsonApiOperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, - IJsonApiRequest request, ITargetedFields targetedFields) - : base(options, resourceGraph, loggerFactory, processor, request, targetedFields) - { - } - /// [HttpPost] - public override async Task PostOperationsAsync([FromBody] IList operations, CancellationToken cancellationToken) + public override async Task PostOperationsAsync([Required] IList operations, CancellationToken cancellationToken) { return await base.PostOperationsAsync(operations, cancellationToken); } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs index fa101c2118..6db14e9fde 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs @@ -15,15 +15,7 @@ namespace JsonApiDotNetCore.Controllers; /// /// The resource identifier type. /// -public abstract class JsonApiQueryController : JsonApiController - where TResource : class, IIdentifiable -{ - /// - /// Creates an instance from a read-only service. - /// - protected JsonApiQueryController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IResourceQueryService queryService) - : base(options, resourceGraph, loggerFactory, queryService, queryService, queryService, queryService) - { - } -} +public abstract class JsonApiQueryController( + IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceQueryService queryService) + : JsonApiController(options, resourceGraph, loggerFactory, queryService, queryService, queryService, queryService) + where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/Controllers/PreserveEmptyStringAttribute.cs b/src/JsonApiDotNetCore/Controllers/PreserveEmptyStringAttribute.cs new file mode 100644 index 0000000000..d76f94ace1 --- /dev/null +++ b/src/JsonApiDotNetCore/Controllers/PreserveEmptyStringAttribute.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Controllers; + +[PublicAPI] +[AttributeUsage(AttributeTargets.Parameter)] +public sealed class PreserveEmptyStringAttribute : DisplayFormatAttribute +{ + public PreserveEmptyStringAttribute() + { + // Workaround for https://github.com/dotnet/aspnetcore/issues/29948#issuecomment-1898216682 + ConvertEmptyStringToNull = false; + } +} diff --git a/src/JsonApiDotNetCore/Diagnostics/AspNetCodeTimerSession.cs b/src/JsonApiDotNetCore/Diagnostics/AspNetCodeTimerSession.cs index bef29dba78..9b58dc43e7 100644 --- a/src/JsonApiDotNetCore/Diagnostics/AspNetCodeTimerSession.cs +++ b/src/JsonApiDotNetCore/Diagnostics/AspNetCodeTimerSession.cs @@ -37,14 +37,14 @@ public ICodeTimer CodeTimer public AspNetCodeTimerSession(IHttpContextAccessor httpContextAccessor) { - ArgumentGuard.NotNull(httpContextAccessor, nameof(httpContextAccessor)); + ArgumentNullException.ThrowIfNull(httpContextAccessor); _httpContextAccessor = httpContextAccessor; } public AspNetCodeTimerSession(HttpContext httpContext) { - ArgumentGuard.NotNull(httpContext, nameof(httpContext)); + ArgumentNullException.ThrowIfNull(httpContext); _httpContext = httpContext; } diff --git a/src/JsonApiDotNetCore/Diagnostics/CascadingCodeTimer.cs b/src/JsonApiDotNetCore/Diagnostics/CascadingCodeTimer.cs index 44cc2955d9..48109b4c98 100644 --- a/src/JsonApiDotNetCore/Diagnostics/CascadingCodeTimer.cs +++ b/src/JsonApiDotNetCore/Diagnostics/CascadingCodeTimer.cs @@ -3,6 +3,8 @@ using System.Runtime.InteropServices; using System.Text; +#pragma warning disable CA2000 // Dispose objects before losing scope + namespace JsonApiDotNetCore.Diagnostics; /// @@ -12,13 +14,13 @@ internal sealed class CascadingCodeTimer : ICodeTimer { private readonly Stopwatch _stopwatch = new(); private readonly Stack _activeScopeStack = new(); - private readonly List _completedScopes = new(); + private readonly List _completedScopes = []; static CascadingCodeTimer() { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - // Be default, measurements using Stopwatch can differ 25%-30% on the same function on the same computer. + // By default, measurements using Stopwatch can differ 25%-30% on the same function on the same computer. // The steps below ensure to get an accuracy of 0.1%-0.2%. With this accuracy, algorithms can be tested and compared. // https://www.codeproject.com/Articles/61964/Performance-Tests-Precise-Run-Time-Measurements-wi @@ -65,7 +67,7 @@ private void Close(MeasureScope scope) _activeScopeStack.Pop(); - if (!_activeScopeStack.Any()) + if (_activeScopeStack.Count == 0) { _completedScopes.Add(scope); } @@ -92,7 +94,7 @@ private int GetPaddingLength() maxLength = Math.Max(maxLength, nextLength); } - if (_activeScopeStack.Any()) + if (_activeScopeStack.Count > 0) { MeasureScope scope = _activeScopeStack.Peek(); int nextLength = scope.GetPaddingLength(); @@ -109,7 +111,7 @@ private void WriteResult(StringBuilder builder, int paddingLength) scope.WriteResult(builder, 0, paddingLength); } - if (_activeScopeStack.Any()) + if (_activeScopeStack.Count > 0) { MeasureScope scope = _activeScopeStack.Peek(); scope.WriteResult(builder, 0, paddingLength); @@ -130,7 +132,7 @@ public void Dispose() private sealed class MeasureScope : IDisposable { private readonly CascadingCodeTimer _owner; - private readonly IList _children = new List(); + private readonly List _children = []; private readonly bool _excludeInRelativeCost; private readonly TimeSpan _startedAt; private TimeSpan? _stoppedAt; @@ -238,12 +240,12 @@ private void WriteResult(StringBuilder builder, int indent, TimeSpan timeElapsed WriteIndent(builder, indent); builder.Append(Name); WritePadding(builder, indent, paddingLength); - builder.AppendFormat(CultureInfo.InvariantCulture, "{0,19:G}", timeElapsedInSelf); + builder.Append(CultureInfo.InvariantCulture, $"{timeElapsedInSelf,19:G}"); if (!_excludeInRelativeCost) { builder.Append(" ... "); - builder.AppendFormat(CultureInfo.InvariantCulture, "{0,7:#0.00%}", scaleElapsedInSelf); + builder.Append(CultureInfo.InvariantCulture, $"{scaleElapsedInSelf,7:#0.00%}"); } if (_stoppedAt == null) diff --git a/src/JsonApiDotNetCore/Diagnostics/CodeTimingSessionManager.cs b/src/JsonApiDotNetCore/Diagnostics/CodeTimingSessionManager.cs index d858aa6f4b..adf642c3b8 100644 --- a/src/JsonApiDotNetCore/Diagnostics/CodeTimingSessionManager.cs +++ b/src/JsonApiDotNetCore/Diagnostics/CodeTimingSessionManager.cs @@ -10,7 +10,7 @@ namespace JsonApiDotNetCore.Diagnostics; /// public static class CodeTimingSessionManager { - public static readonly bool IsEnabled; + public static readonly bool IsEnabled = GetDefaultIsEnabled(); private static ICodeTimerSession? _session; public static ICodeTimer Current @@ -28,12 +28,12 @@ public static ICodeTimer Current } } - static CodeTimingSessionManager() + private static bool GetDefaultIsEnabled() { #if DEBUG - IsEnabled = !IsRunningInTest() && !IsRunningInBenchmark(); + return !IsRunningInTest() && !IsRunningInBenchmark() && !IsGeneratingOpenApiDocumentAtBuildTime(); #else - IsEnabled = false; + return false; #endif } @@ -52,6 +52,12 @@ private static bool IsRunningInBenchmark() return Assembly.GetEntryAssembly()?.GetName().Name == "Benchmarks"; } + // ReSharper disable once UnusedMember.Local + private static bool IsGeneratingOpenApiDocumentAtBuildTime() + { + return Environment.GetCommandLineArgs().Any(argument => argument.Contains("GetDocument.Insider")); + } + private static void AssertHasActiveSession() { if (_session == null) @@ -62,7 +68,7 @@ private static void AssertHasActiveSession() public static void Capture(ICodeTimerSession session) { - ArgumentGuard.NotNull(session, nameof(session)); + ArgumentNullException.ThrowIfNull(session); AssertNoActiveSession(); diff --git a/src/JsonApiDotNetCore/Diagnostics/DefaultCodeTimerSession.cs b/src/JsonApiDotNetCore/Diagnostics/DefaultCodeTimerSession.cs index a1662095cb..6ee970711a 100644 --- a/src/JsonApiDotNetCore/Diagnostics/DefaultCodeTimerSession.cs +++ b/src/JsonApiDotNetCore/Diagnostics/DefaultCodeTimerSession.cs @@ -2,7 +2,7 @@ namespace JsonApiDotNetCore.Diagnostics; /// /// General code timing session management. Can be used with async/wait, but it cannot distinguish between concurrently running threads, so you'll need -/// to pass an instance through the entire call chain in that case. +/// to pass a instance through the entire call chain in that case. /// public sealed class DefaultCodeTimerSession : ICodeTimerSession { @@ -27,10 +27,7 @@ public DefaultCodeTimerSession() private void AssertNotDisposed() { - if (_codeTimerInContext.Value == null) - { - throw new ObjectDisposedException(nameof(DefaultCodeTimerSession)); - } + ObjectDisposedException.ThrowIf(_codeTimerInContext.Value == null, this); } public void Dispose() diff --git a/src/JsonApiDotNetCore/Errors/CannotClearRequiredRelationshipException.cs b/src/JsonApiDotNetCore/Errors/CannotClearRequiredRelationshipException.cs index b73b256d25..0a9db40c04 100644 --- a/src/JsonApiDotNetCore/Errors/CannotClearRequiredRelationshipException.cs +++ b/src/JsonApiDotNetCore/Errors/CannotClearRequiredRelationshipException.cs @@ -8,15 +8,9 @@ namespace JsonApiDotNetCore.Errors; /// The error that is thrown when a required relationship is cleared. /// [PublicAPI] -public sealed class CannotClearRequiredRelationshipException : JsonApiException -{ - public CannotClearRequiredRelationshipException(string relationshipName, string resourceId, string resourceType) - : base(new ErrorObject(HttpStatusCode.BadRequest) - { - Title = "Failed to clear a required relationship.", - Detail = $"The relationship '{relationshipName}' on resource type '{resourceType}' " + - $"with ID '{resourceId}' cannot be cleared because it is a required relationship." - }) +public sealed class CannotClearRequiredRelationshipException(string relationshipName, string resourceType) + : JsonApiException(new ErrorObject(HttpStatusCode.BadRequest) { - } -} + Title = "Failed to clear a required relationship.", + Detail = $"The relationship '{relationshipName}' on resource type '{resourceType}' cannot be cleared because it is a required relationship." + }); diff --git a/src/JsonApiDotNetCore/Errors/DuplicateLocalIdValueException.cs b/src/JsonApiDotNetCore/Errors/DuplicateLocalIdValueException.cs index 7b714d2b9d..689529356b 100644 --- a/src/JsonApiDotNetCore/Errors/DuplicateLocalIdValueException.cs +++ b/src/JsonApiDotNetCore/Errors/DuplicateLocalIdValueException.cs @@ -8,14 +8,9 @@ namespace JsonApiDotNetCore.Errors; /// The error that is thrown when assigning a local ID that was already assigned in an earlier operation. /// [PublicAPI] -public sealed class DuplicateLocalIdValueException : JsonApiException -{ - public DuplicateLocalIdValueException(string localId) - : base(new ErrorObject(HttpStatusCode.BadRequest) - { - Title = "Another local ID with the same name is already defined at this point.", - Detail = $"Another local ID with name '{localId}' is already defined at this point." - }) +public sealed class DuplicateLocalIdValueException(string localId) + : JsonApiException(new ErrorObject(HttpStatusCode.BadRequest) { - } -} + Title = "Another local ID with the same name is already defined at this point.", + Detail = $"Another local ID with name '{localId}' is already defined at this point." + }); diff --git a/src/JsonApiDotNetCore/Errors/FailedOperationException.cs b/src/JsonApiDotNetCore/Errors/FailedOperationException.cs index e94b5a7c1b..53bc2e1e5c 100644 --- a/src/JsonApiDotNetCore/Errors/FailedOperationException.cs +++ b/src/JsonApiDotNetCore/Errors/FailedOperationException.cs @@ -8,18 +8,13 @@ namespace JsonApiDotNetCore.Errors; /// The error that is thrown when an operation in an atomic:operations request failed to be processed for unknown reasons. /// [PublicAPI] -public sealed class FailedOperationException : JsonApiException -{ - public FailedOperationException(int operationIndex, Exception innerException) - : base(new ErrorObject(HttpStatusCode.InternalServerError) - { - Title = "An unhandled error occurred while processing an operation in this request.", - Detail = innerException.Message, - Source = new ErrorSource - { - Pointer = $"/atomic:operations[{operationIndex}]" - } - }, innerException) +public sealed class FailedOperationException(int operationIndex, Exception innerException) + : JsonApiException(new ErrorObject(HttpStatusCode.InternalServerError) { - } -} + Title = "An unhandled error occurred while processing an operation in this request.", + Detail = innerException.Message, + Source = new ErrorSource + { + Pointer = $"/atomic:operations[{operationIndex}]" + } + }, innerException); diff --git a/src/JsonApiDotNetCore/Errors/IncompatibleLocalIdTypeException.cs b/src/JsonApiDotNetCore/Errors/IncompatibleLocalIdTypeException.cs index f3f7b33bb5..0e6bfab8cb 100644 --- a/src/JsonApiDotNetCore/Errors/IncompatibleLocalIdTypeException.cs +++ b/src/JsonApiDotNetCore/Errors/IncompatibleLocalIdTypeException.cs @@ -8,14 +8,9 @@ namespace JsonApiDotNetCore.Errors; /// The error that is thrown when referencing a local ID that was assigned to a different resource type. /// [PublicAPI] -public sealed class IncompatibleLocalIdTypeException : JsonApiException -{ - public IncompatibleLocalIdTypeException(string localId, string declaredType, string currentType) - : base(new ErrorObject(HttpStatusCode.BadRequest) - { - Title = "Incompatible type in Local ID usage.", - Detail = $"Local ID '{localId}' belongs to resource type '{declaredType}' instead of '{currentType}'." - }) +public sealed class IncompatibleLocalIdTypeException(string localId, string declaredType, string currentType) + : JsonApiException(new ErrorObject(HttpStatusCode.BadRequest) { - } -} + Title = "Incompatible type in Local ID usage.", + Detail = $"Local ID '{localId}' belongs to resource type '{declaredType}' instead of '{currentType}'." + }); diff --git a/src/JsonApiDotNetCore/Errors/InvalidConfigurationException.cs b/src/JsonApiDotNetCore/Errors/InvalidConfigurationException.cs index e25fa5244c..5e659bfc05 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidConfigurationException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidConfigurationException.cs @@ -6,10 +6,5 @@ namespace JsonApiDotNetCore.Errors; /// The error that is thrown when configured usage of this library is invalid. /// [PublicAPI] -public sealed class InvalidConfigurationException : Exception -{ - public InvalidConfigurationException(string message, Exception? innerException = null) - : base(message, innerException) - { - } -} +public sealed class InvalidConfigurationException(string message, Exception? innerException = null) + : Exception(message, innerException); diff --git a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs index eede4fed25..fc85156e57 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs @@ -15,22 +15,19 @@ namespace JsonApiDotNetCore.Errors; /// The error that is thrown when ASP.NET ModelState validation fails. /// [PublicAPI] -public sealed class InvalidModelStateException : JsonApiException +public sealed class InvalidModelStateException( + IReadOnlyDictionary modelState, Type modelType, bool includeExceptionStackTraceInErrors, IResourceGraph resourceGraph, + Func? getCollectionElementTypeCallback = null) + : JsonApiException(FromModelStateDictionary(modelState, modelType, resourceGraph, includeExceptionStackTraceInErrors, getCollectionElementTypeCallback)) { - public InvalidModelStateException(IReadOnlyDictionary modelState, Type modelType, bool includeExceptionStackTraceInErrors, - IResourceGraph resourceGraph, Func? getCollectionElementTypeCallback = null) - : base(FromModelStateDictionary(modelState, modelType, resourceGraph, includeExceptionStackTraceInErrors, getCollectionElementTypeCallback)) - { - } - - private static IEnumerable FromModelStateDictionary(IReadOnlyDictionary modelState, Type modelType, + private static List FromModelStateDictionary(IReadOnlyDictionary modelState, Type modelType, IResourceGraph resourceGraph, bool includeExceptionStackTraceInErrors, Func? getCollectionElementTypeCallback) { - ArgumentGuard.NotNull(modelState, nameof(modelState)); - ArgumentGuard.NotNull(modelType, nameof(modelType)); - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + ArgumentNullException.ThrowIfNull(modelState); + ArgumentNullException.ThrowIfNull(modelType); + ArgumentNullException.ThrowIfNull(resourceGraph); - List errorObjects = new(); + List errorObjects = []; foreach ((ModelStateEntry entry, string? sourcePointer) in ResolveSourcePointers(modelState, modelType, resourceGraph, getCollectionElementTypeCallback)) @@ -67,7 +64,7 @@ private static IEnumerable FromModelStateDictionary(IReadOnlyDictio return ResolveSourcePointerInComplexType(propertySegment, resourceGraph); } - if (propertySegment.PropertyName == nameof(OperationContainer.Resource) && propertySegment.Parent != null && + if (propertySegment is { PropertyName: nameof(OperationContainer.Resource), Parent: not null } && propertySegment.Parent.ModelType == typeof(IList)) { // Special case: Stepping over OperationContainer.Resource property. @@ -189,7 +186,7 @@ private static ErrorObject FromModelError(ModelError modelError, string? sourceP Exception exception = modelError.Exception.Demystify(); string[] stackTraceLines = exception.ToString().Split(Environment.NewLine); - if (stackTraceLines.Any()) + if (stackTraceLines.Length > 0) { error.Meta ??= new Dictionary(); error.Meta["StackTrace"] = stackTraceLines; @@ -207,7 +204,12 @@ private abstract class ModelStateKeySegment private const char Dot = '.'; private const char BracketOpen = '['; private const char BracketClose = ']'; - private static readonly char[] KeySegmentStartTokens = ArrayFactory.Create(Dot, BracketOpen); + + private static readonly char[] KeySegmentStartTokens = + [ + Dot, + BracketOpen + ]; // The right part of the full key, which nested segments are produced from. private readonly string _nextKey; @@ -229,8 +231,8 @@ private abstract class ModelStateKeySegment protected ModelStateKeySegment(Type modelType, bool isInComplexType, string nextKey, string? sourcePointer, ModelStateKeySegment? parent, Func? getCollectionElementTypeCallback) { - ArgumentGuard.NotNull(modelType, nameof(modelType)); - ArgumentGuard.NotNull(nextKey, nameof(nextKey)); + ArgumentNullException.ThrowIfNull(modelType); + ArgumentNullException.ThrowIfNull(nextKey); ModelType = modelType; IsInComplexType = isInComplexType; @@ -242,15 +244,15 @@ protected ModelStateKeySegment(Type modelType, bool isInComplexType, string next public ModelStateKeySegment? GetNextSegment(Type modelType, bool isInComplexType, string? sourcePointer) { - ArgumentGuard.NotNull(modelType, nameof(modelType)); + ArgumentNullException.ThrowIfNull(modelType); - return _nextKey == string.Empty ? null : CreateSegment(modelType, _nextKey, isInComplexType, this, sourcePointer, GetCollectionElementTypeCallback); + return _nextKey.Length == 0 ? null : CreateSegment(modelType, _nextKey, isInComplexType, this, sourcePointer, GetCollectionElementTypeCallback); } public static ModelStateKeySegment Create(Type modelType, string key, Func? getCollectionElementTypeCallback) { - ArgumentGuard.NotNull(modelType, nameof(modelType)); - ArgumentGuard.NotNull(key, nameof(key)); + ArgumentNullException.ThrowIfNull(modelType); + ArgumentNullException.ThrowIfNull(key); return CreateSegment(modelType, key, false, null, null, getCollectionElementTypeCallback); } @@ -269,7 +271,7 @@ private static ModelStateKeySegment CreateSegment(Type modelType, string key, bo if (bracketCloseIndex != -1) { - segmentValue = key[1.. bracketCloseIndex]; + segmentValue = key[1..bracketCloseIndex]; int nextKeyStartIndex = key.Length > bracketCloseIndex + 1 && key[bracketCloseIndex + 1] == Dot ? bracketCloseIndex + 2 @@ -312,18 +314,12 @@ private static ModelStateKeySegment CreateSegment(Type modelType, string key, bo /// /// Represents an array indexer in a ModelState key, such as "1" in "Customer.Orders[1].Amount". /// - private sealed class ArrayIndexerSegment : ModelStateKeySegment + private sealed class ArrayIndexerSegment( + int arrayIndex, Type modelType, bool isInComplexType, string nextKey, string? sourcePointer, ModelStateKeySegment? parent, + Func? getCollectionElementTypeCallback) + : ModelStateKeySegment(modelType, isInComplexType, nextKey, sourcePointer, parent, getCollectionElementTypeCallback) { - private static readonly CollectionConverter CollectionConverter = new(); - - public int ArrayIndex { get; } - - public ArrayIndexerSegment(int arrayIndex, Type modelType, bool isInComplexType, string nextKey, string? sourcePointer, ModelStateKeySegment? parent, - Func? getCollectionElementTypeCallback) - : base(modelType, isInComplexType, nextKey, sourcePointer, parent, getCollectionElementTypeCallback) - { - ArrayIndex = arrayIndex; - } + public int ArrayIndex { get; } = arrayIndex; public Type GetCollectionElementType() { @@ -335,7 +331,7 @@ private Type GetDeclaredCollectionElementType() { if (ModelType != typeof(string)) { - Type? elementType = CollectionConverter.FindCollectionElementType(ModelType); + Type? elementType = CollectionConverter.Instance.FindCollectionElementType(ModelType); if (elementType != null) { @@ -359,14 +355,14 @@ public PropertySegment(string propertyName, Type modelType, bool isInComplexType Func? getCollectionElementTypeCallback) : base(modelType, isInComplexType, nextKey, sourcePointer, parent, getCollectionElementTypeCallback) { - ArgumentGuard.NotNull(propertyName, nameof(propertyName)); + ArgumentNullException.ThrowIfNull(propertyName); PropertyName = propertyName; } public static string GetPublicNameForProperty(PropertyInfo property) { - ArgumentGuard.NotNull(property, nameof(property)); + ArgumentNullException.ThrowIfNull(property); var jsonNameAttribute = property.GetCustomAttribute(true); return jsonNameAttribute?.Name ?? property.Name; diff --git a/src/JsonApiDotNetCore/Errors/InvalidQueryException.cs b/src/JsonApiDotNetCore/Errors/InvalidQueryException.cs index f4332c1af1..36ae294140 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidQueryException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidQueryException.cs @@ -9,14 +9,9 @@ namespace JsonApiDotNetCore.Errors; /// The error that is thrown when translating a to Entity Framework Core fails. /// [PublicAPI] -public sealed class InvalidQueryException : JsonApiException -{ - public InvalidQueryException(string reason, Exception? innerException) - : base(new ErrorObject(HttpStatusCode.BadRequest) - { - Title = reason, - Detail = innerException?.Message - }, innerException) +public sealed class InvalidQueryException(string reason, Exception? innerException) + : JsonApiException(new ErrorObject(HttpStatusCode.BadRequest) { - } -} + Title = reason, + Detail = innerException?.Message + }, innerException); diff --git a/src/JsonApiDotNetCore/Errors/InvalidQueryStringParameterException.cs b/src/JsonApiDotNetCore/Errors/InvalidQueryStringParameterException.cs index 485cf3685b..d676e40111 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidQueryStringParameterException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidQueryStringParameterException.cs @@ -8,21 +8,16 @@ namespace JsonApiDotNetCore.Errors; /// The error that is thrown when processing the request fails due to an error in the request query string. /// [PublicAPI] -public sealed class InvalidQueryStringParameterException : JsonApiException -{ - public string ParameterName { get; } - - public InvalidQueryStringParameterException(string parameterName, string genericMessage, string specificMessage, Exception? innerException = null) - : base(new ErrorObject(HttpStatusCode.BadRequest) - { - Title = genericMessage, - Detail = specificMessage, - Source = new ErrorSource - { - Parameter = parameterName - } - }, innerException) +public sealed class InvalidQueryStringParameterException(string parameterName, string genericMessage, string specificMessage, Exception? innerException = null) + : JsonApiException(new ErrorObject(HttpStatusCode.BadRequest) { - ParameterName = parameterName; - } + Title = genericMessage, + Detail = specificMessage, + Source = new ErrorSource + { + Parameter = parameterName + } + }, innerException) +{ + public string ParameterName { get; } = parameterName; } diff --git a/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs b/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs index d8d752ece4..6e873eb388 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs @@ -2,33 +2,31 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; +#pragma warning disable format + namespace JsonApiDotNetCore.Errors; /// /// The error that is thrown when deserializing the request body fails. /// [PublicAPI] -public sealed class InvalidRequestBodyException : JsonApiException -{ - public InvalidRequestBodyException(string? requestBody, string? genericMessage, string? specificMessage, string? sourcePointer, - HttpStatusCode? alternativeStatusCode = null, Exception? innerException = null) - : base(new ErrorObject(alternativeStatusCode ?? HttpStatusCode.UnprocessableEntity) - { - Title = genericMessage != null ? $"Failed to deserialize request body: {genericMessage}" : "Failed to deserialize request body.", - Detail = specificMessage, - Source = sourcePointer == null - ? null - : new ErrorSource - { - Pointer = sourcePointer - }, - Meta = string.IsNullOrEmpty(requestBody) - ? null - : new Dictionary - { - ["RequestBody"] = requestBody - } - }, innerException) +public sealed class InvalidRequestBodyException( + string? requestBody, string? genericMessage, string? specificMessage, string? sourcePointer, HttpStatusCode? alternativeStatusCode = null, + Exception? innerException = null) + : JsonApiException(new ErrorObject(alternativeStatusCode ?? HttpStatusCode.UnprocessableEntity) { - } -} + Title = genericMessage != null ? $"Failed to deserialize request body: {genericMessage}" : "Failed to deserialize request body.", + Detail = specificMessage, + Source = sourcePointer == null + ? null + : new ErrorSource + { + Pointer = sourcePointer + }, + Meta = string.IsNullOrEmpty(requestBody) + ? null + : new Dictionary + { + ["RequestBody"] = requestBody + } + }, innerException); diff --git a/src/JsonApiDotNetCore/Errors/JsonApiException.cs b/src/JsonApiDotNetCore/Errors/JsonApiException.cs index 6bb62177dc..b44b18d5a8 100644 --- a/src/JsonApiDotNetCore/Errors/JsonApiException.cs +++ b/src/JsonApiDotNetCore/Errors/JsonApiException.cs @@ -1,3 +1,4 @@ +using System.Collections.ObjectModel; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; @@ -24,23 +25,23 @@ public class JsonApiException : Exception public JsonApiException(ErrorObject error, Exception? innerException = null) : base(null, innerException) { - ArgumentGuard.NotNull(error, nameof(error)); + ArgumentNullException.ThrowIfNull(error); - Errors = error.AsArray(); + Errors = [error]; } public JsonApiException(IEnumerable errors, Exception? innerException = null) : base(null, innerException) { - IReadOnlyList? errorList = ToErrorList(errors); - ArgumentGuard.NotNullNorEmpty(errorList, nameof(errors)); + ReadOnlyCollection? errorCollection = ToCollection(errors); + ArgumentGuard.NotNullNorEmpty(errorCollection, nameof(errors)); - Errors = errorList; + Errors = errorCollection; } - private static IReadOnlyList? ToErrorList(IEnumerable? errors) + private static ReadOnlyCollection? ToCollection(IEnumerable? errors) { - return errors?.ToList(); + return errors?.ToArray().AsReadOnly(); } public string GetSummary() diff --git a/src/JsonApiDotNetCore/Errors/LocalIdSingleOperationException.cs b/src/JsonApiDotNetCore/Errors/LocalIdSingleOperationException.cs index d99eb6dfbb..44ca7ecc4c 100644 --- a/src/JsonApiDotNetCore/Errors/LocalIdSingleOperationException.cs +++ b/src/JsonApiDotNetCore/Errors/LocalIdSingleOperationException.cs @@ -8,14 +8,9 @@ namespace JsonApiDotNetCore.Errors; /// The error that is thrown when assigning and referencing a local ID within the same operation. /// [PublicAPI] -public sealed class LocalIdSingleOperationException : JsonApiException -{ - public LocalIdSingleOperationException(string localId) - : base(new ErrorObject(HttpStatusCode.BadRequest) - { - Title = "Local ID cannot be both defined and used within the same operation.", - Detail = $"Local ID '{localId}' cannot be both defined and used within the same operation." - }) +public sealed class LocalIdSingleOperationException(string localId) + : JsonApiException(new ErrorObject(HttpStatusCode.BadRequest) { - } -} + Title = "Local ID cannot be both defined and used within the same operation.", + Detail = $"Local ID '{localId}' cannot be both defined and used within the same operation." + }); diff --git a/src/JsonApiDotNetCore/Errors/MissingResourceInRelationship.cs b/src/JsonApiDotNetCore/Errors/MissingResourceInRelationship.cs index e80181898d..de38f61fcd 100644 --- a/src/JsonApiDotNetCore/Errors/MissingResourceInRelationship.cs +++ b/src/JsonApiDotNetCore/Errors/MissingResourceInRelationship.cs @@ -11,9 +11,9 @@ public sealed class MissingResourceInRelationship public MissingResourceInRelationship(string relationshipName, string resourceType, string resourceId) { - ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); - ArgumentGuard.NotNullNorEmpty(resourceType, nameof(resourceType)); - ArgumentGuard.NotNullNorEmpty(resourceId, nameof(resourceId)); + ArgumentNullException.ThrowIfNull(relationshipName); + ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(resourceId); RelationshipName = relationshipName; ResourceType = resourceType; diff --git a/src/JsonApiDotNetCore/Errors/MissingTransactionSupportException.cs b/src/JsonApiDotNetCore/Errors/MissingTransactionSupportException.cs index f67dd0d243..c8b182ad53 100644 --- a/src/JsonApiDotNetCore/Errors/MissingTransactionSupportException.cs +++ b/src/JsonApiDotNetCore/Errors/MissingTransactionSupportException.cs @@ -8,14 +8,9 @@ namespace JsonApiDotNetCore.Errors; /// The error that is thrown when accessing a repository that does not support transactions during an atomic:operations request. /// [PublicAPI] -public sealed class MissingTransactionSupportException : JsonApiException -{ - public MissingTransactionSupportException(string resourceType) - : base(new ErrorObject(HttpStatusCode.UnprocessableEntity) - { - Title = "Unsupported resource type in atomic:operations request.", - Detail = $"Operations on resources of type '{resourceType}' cannot be used because transaction support is unavailable." - }) +public sealed class MissingTransactionSupportException(string resourceType) + : JsonApiException(new ErrorObject(HttpStatusCode.UnprocessableEntity) { - } -} + Title = "Unsupported resource type in atomic:operations request.", + Detail = $"Operations on resources of type '{resourceType}' cannot be used because transaction support is unavailable." + }); diff --git a/src/JsonApiDotNetCore/Errors/NonParticipatingTransactionException.cs b/src/JsonApiDotNetCore/Errors/NonParticipatingTransactionException.cs index 09e116c4c1..24b8634f9e 100644 --- a/src/JsonApiDotNetCore/Errors/NonParticipatingTransactionException.cs +++ b/src/JsonApiDotNetCore/Errors/NonParticipatingTransactionException.cs @@ -8,14 +8,9 @@ namespace JsonApiDotNetCore.Errors; /// The error that is thrown when a repository does not participate in the overarching transaction during an atomic:operations request. /// [PublicAPI] -public sealed class NonParticipatingTransactionException : JsonApiException -{ - public NonParticipatingTransactionException() - : base(new ErrorObject(HttpStatusCode.UnprocessableEntity) - { - Title = "Unsupported combination of resource types in atomic:operations request.", - Detail = "All operations need to participate in a single shared transaction, which is not the case for this request." - }) +public sealed class NonParticipatingTransactionException() + : JsonApiException(new ErrorObject(HttpStatusCode.UnprocessableEntity) { - } -} + Title = "Unsupported combination of resource types in atomic:operations request.", + Detail = "All operations need to participate in a single shared transaction, which is not the case for this request." + }); diff --git a/src/JsonApiDotNetCore/Errors/RelationshipNotFoundException.cs b/src/JsonApiDotNetCore/Errors/RelationshipNotFoundException.cs index 5c4d59e479..7fc5c92f66 100644 --- a/src/JsonApiDotNetCore/Errors/RelationshipNotFoundException.cs +++ b/src/JsonApiDotNetCore/Errors/RelationshipNotFoundException.cs @@ -8,14 +8,9 @@ namespace JsonApiDotNetCore.Errors; /// The error that is thrown when a relationship does not exist. /// [PublicAPI] -public sealed class RelationshipNotFoundException : JsonApiException -{ - public RelationshipNotFoundException(string relationshipName, string resourceType) - : base(new ErrorObject(HttpStatusCode.NotFound) - { - Title = "The requested relationship does not exist.", - Detail = $"Resource of type '{resourceType}' does not contain a relationship named '{relationshipName}'." - }) +public sealed class RelationshipNotFoundException(string relationshipName, string resourceType) + : JsonApiException(new ErrorObject(HttpStatusCode.NotFound) { - } -} + Title = "The requested relationship does not exist.", + Detail = $"Resource of type '{resourceType}' does not contain a relationship named '{relationshipName}'." + }); diff --git a/src/JsonApiDotNetCore/Errors/ResourceAlreadyExistsException.cs b/src/JsonApiDotNetCore/Errors/ResourceAlreadyExistsException.cs index eb4a444d42..518b59ef21 100644 --- a/src/JsonApiDotNetCore/Errors/ResourceAlreadyExistsException.cs +++ b/src/JsonApiDotNetCore/Errors/ResourceAlreadyExistsException.cs @@ -8,14 +8,9 @@ namespace JsonApiDotNetCore.Errors; /// The error that is thrown when creating a resource with an ID that already exists. /// [PublicAPI] -public sealed class ResourceAlreadyExistsException : JsonApiException -{ - public ResourceAlreadyExistsException(string resourceId, string resourceType) - : base(new ErrorObject(HttpStatusCode.Conflict) - { - Title = "Another resource with the specified ID already exists.", - Detail = $"Another resource of type '{resourceType}' with ID '{resourceId}' already exists." - }) +public sealed class ResourceAlreadyExistsException(string resourceId, string resourceType) + : JsonApiException(new ErrorObject(HttpStatusCode.Conflict) { - } -} + Title = "Another resource with the specified ID already exists.", + Detail = $"Another resource of type '{resourceType}' with ID '{resourceId}' already exists." + }); diff --git a/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs b/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs index 28b60851c3..27ac99cafd 100644 --- a/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs +++ b/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs @@ -8,14 +8,9 @@ namespace JsonApiDotNetCore.Errors; /// The error that is thrown when a resource does not exist. /// [PublicAPI] -public sealed class ResourceNotFoundException : JsonApiException -{ - public ResourceNotFoundException(string resourceId, string resourceType) - : base(new ErrorObject(HttpStatusCode.NotFound) - { - Title = "The requested resource does not exist.", - Detail = $"Resource of type '{resourceType}' with ID '{resourceId}' does not exist." - }) +public sealed class ResourceNotFoundException(string resourceId, string resourceType) + : JsonApiException(new ErrorObject(HttpStatusCode.NotFound) { - } -} + Title = "The requested resource does not exist.", + Detail = $"Resource of type '{resourceType}' with ID '{resourceId}' does not exist." + }); diff --git a/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipsNotFoundException.cs b/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipsNotFoundException.cs index f40f76188b..81d0a14dc8 100644 --- a/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipsNotFoundException.cs +++ b/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipsNotFoundException.cs @@ -8,13 +8,9 @@ namespace JsonApiDotNetCore.Errors; /// The error that is thrown when referencing one or more non-existing resources in one or more relationships. /// [PublicAPI] -public sealed class ResourcesInRelationshipsNotFoundException : JsonApiException +public sealed class ResourcesInRelationshipsNotFoundException(IEnumerable missingResources) + : JsonApiException(missingResources.Select(CreateError)) { - public ResourcesInRelationshipsNotFoundException(IEnumerable missingResources) - : base(missingResources.Select(CreateError)) - { - } - private static ErrorObject CreateError(MissingResourceInRelationship missingResourceInRelationship) { return new ErrorObject(HttpStatusCode.NotFound) diff --git a/src/JsonApiDotNetCore/Errors/RouteNotAvailableException.cs b/src/JsonApiDotNetCore/Errors/RouteNotAvailableException.cs index 80a368379b..d04d2c599b 100644 --- a/src/JsonApiDotNetCore/Errors/RouteNotAvailableException.cs +++ b/src/JsonApiDotNetCore/Errors/RouteNotAvailableException.cs @@ -8,17 +8,12 @@ namespace JsonApiDotNetCore.Errors; /// The error that is thrown when a request is received for an HTTP route that is not exposed. /// [PublicAPI] -public sealed class RouteNotAvailableException : JsonApiException -{ - public HttpMethod Method { get; } - - public RouteNotAvailableException(HttpMethod method, string route) - : base(new ErrorObject(HttpStatusCode.Forbidden) - { - Title = "The requested endpoint is not accessible.", - Detail = $"Endpoint '{route}' is not accessible for {method} requests." - }) +public sealed class RouteNotAvailableException(HttpMethod method, string route) + : JsonApiException(new ErrorObject(HttpStatusCode.Forbidden) { - Method = method; - } + Title = "The requested endpoint is not accessible.", + Detail = $"Endpoint '{route}' is not accessible for {method} requests." + }) +{ + public HttpMethod Method { get; } = method; } diff --git a/src/JsonApiDotNetCore/Errors/UnknownLocalIdValueException.cs b/src/JsonApiDotNetCore/Errors/UnknownLocalIdValueException.cs index b5eeef052e..023f577912 100644 --- a/src/JsonApiDotNetCore/Errors/UnknownLocalIdValueException.cs +++ b/src/JsonApiDotNetCore/Errors/UnknownLocalIdValueException.cs @@ -8,14 +8,9 @@ namespace JsonApiDotNetCore.Errors; /// The error that is thrown when referencing a local ID that hasn't been assigned. /// [PublicAPI] -public sealed class UnknownLocalIdValueException : JsonApiException -{ - public UnknownLocalIdValueException(string localId) - : base(new ErrorObject(HttpStatusCode.BadRequest) - { - Title = "Server-generated value for local ID is not available at this point.", - Detail = $"Server-generated value for local ID '{localId}' is not available at this point." - }) +public sealed class UnknownLocalIdValueException(string localId) + : JsonApiException(new ErrorObject(HttpStatusCode.BadRequest) { - } -} + Title = "Server-generated value for local ID is not available at this point.", + Detail = $"Server-generated value for local ID '{localId}' is not available at this point." + }); diff --git a/src/JsonApiDotNetCore/Errors/UnsuccessfulActionResultException.cs b/src/JsonApiDotNetCore/Errors/UnsuccessfulActionResultException.cs index c5c55e4b70..9e8083f7dc 100644 --- a/src/JsonApiDotNetCore/Errors/UnsuccessfulActionResultException.cs +++ b/src/JsonApiDotNetCore/Errors/UnsuccessfulActionResultException.cs @@ -1,6 +1,7 @@ using System.Net; using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; namespace JsonApiDotNetCore.Errors; @@ -20,20 +21,35 @@ public UnsuccessfulActionResultException(HttpStatusCode status) } public UnsuccessfulActionResultException(ProblemDetails problemDetails) - : base(ToError(problemDetails)) + : base(ToErrorObjects(problemDetails)) { } - private static ErrorObject ToError(ProblemDetails problemDetails) + private static IEnumerable ToErrorObjects(ProblemDetails problemDetails) { - ArgumentGuard.NotNull(problemDetails, nameof(problemDetails)); + ArgumentNullException.ThrowIfNull(problemDetails); HttpStatusCode status = problemDetails.Status != null ? (HttpStatusCode)problemDetails.Status.Value : HttpStatusCode.InternalServerError; + if (problemDetails is HttpValidationProblemDetails { Errors.Count: > 0 } validationProblemDetails) + { + foreach (string errorMessage in validationProblemDetails.Errors.SelectMany(pair => pair.Value)) + { + yield return ToErrorObject(status, validationProblemDetails, errorMessage); + } + } + else + { + yield return ToErrorObject(status, problemDetails, problemDetails.Detail); + } + } + + private static ErrorObject ToErrorObject(HttpStatusCode status, ProblemDetails problemDetails, string? detail) + { var error = new ErrorObject(status) { Title = problemDetails.Title, - Detail = problemDetails.Detail + Detail = detail }; if (!string.IsNullOrWhiteSpace(problemDetails.Instance)) diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj index e401db38fa..d36600e878 100644 --- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj +++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj @@ -1,30 +1,29 @@ - $(TargetFrameworkName) + net8.0 true true + + - $(JsonApiDotNetCoreVersionPrefix) jsonapidotnetcore;jsonapi;json:api;dotnet;asp.net;rest;web-api - A framework for building JSON:API compliant REST APIs using ASP.NET and Entity Framework Core. Includes support for Atomic Operations. The ultimate goal of this library is to eliminate as much boilerplate as possible by offering out-of-the-box features such as sorting, filtering and pagination. You just need to focus on defining the resources and implementing your custom business logic. This library has been designed around dependency injection making extensibility incredibly easy. + A framework for building JSON:API compliant REST APIs using ASP.NET Core and Entity Framework Core. Includes support for the Atomic Operations extension. The ultimate goal of this library is to eliminate as much boilerplate as possible by offering out-of-the-box features, such as sorting, filtering, pagination, sparse fieldset selection, and side-loading related resources. You just need to focus on defining the resources and implementing your custom business logic. This library has been designed around dependency injection, making extensibility incredibly easy. json-api-dotnet https://www.jsonapi.net/ MIT false See https://github.com/json-api-dotnet/JsonApiDotNetCore/releases. - logo.png + package-icon.png + PackageReadme.md true - true embedded - - True - - + + @@ -37,12 +36,10 @@ - - - - - - - + + + + + diff --git a/src/JsonApiDotNetCore/Middleware/AlwaysEnabledJsonApiEndpointFilter.cs b/src/JsonApiDotNetCore/Middleware/AlwaysEnabledJsonApiEndpointFilter.cs new file mode 100644 index 0000000000..c3918d7462 --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/AlwaysEnabledJsonApiEndpointFilter.cs @@ -0,0 +1,13 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; + +namespace JsonApiDotNetCore.Middleware; + +internal sealed class AlwaysEnabledJsonApiEndpointFilter : IJsonApiEndpointFilter +{ + /// + public bool IsEnabled(ResourceType resourceType, JsonApiEndpoints endpoint) + { + return true; + } +} diff --git a/src/JsonApiDotNetCore/Middleware/AsyncConvertEmptyActionResultFilter.cs b/src/JsonApiDotNetCore/Middleware/AsyncConvertEmptyActionResultFilter.cs index cf51a82733..440d3e69ea 100644 --- a/src/JsonApiDotNetCore/Middleware/AsyncConvertEmptyActionResultFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/AsyncConvertEmptyActionResultFilter.cs @@ -4,14 +4,14 @@ namespace JsonApiDotNetCore.Middleware; -/// +/// public sealed class AsyncConvertEmptyActionResultFilter : IAsyncConvertEmptyActionResultFilter { /// public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next) { - ArgumentGuard.NotNull(context, nameof(context)); - ArgumentGuard.NotNull(next, nameof(next)); + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(next); if (context.HttpContext.IsJsonApiRequest()) { diff --git a/src/JsonApiDotNetCore/Middleware/AsyncJsonApiExceptionFilter.cs b/src/JsonApiDotNetCore/Middleware/AsyncJsonApiExceptionFilter.cs index e87fc98389..915747de8b 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 { @@ -13,7 +13,7 @@ public sealed class AsyncJsonApiExceptionFilter : IAsyncJsonApiExceptionFilter public AsyncJsonApiExceptionFilter(IExceptionHandler exceptionHandler) { - ArgumentGuard.NotNull(exceptionHandler, nameof(exceptionHandler)); + ArgumentNullException.ThrowIfNull(exceptionHandler); _exceptionHandler = exceptionHandler; } @@ -21,7 +21,7 @@ public AsyncJsonApiExceptionFilter(IExceptionHandler exceptionHandler) /// public Task OnExceptionAsync(ExceptionContext context) { - ArgumentGuard.NotNull(context, nameof(context)); + ArgumentNullException.ThrowIfNull(context); if (context.HttpContext.IsJsonApiRequest()) { diff --git a/src/JsonApiDotNetCore/Middleware/AsyncQueryStringActionFilter.cs b/src/JsonApiDotNetCore/Middleware/AsyncQueryStringActionFilter.cs index 6f31c28d2a..e832b4693a 100644 --- a/src/JsonApiDotNetCore/Middleware/AsyncQueryStringActionFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/AsyncQueryStringActionFilter.cs @@ -5,14 +5,14 @@ namespace JsonApiDotNetCore.Middleware; -/// +/// public sealed class AsyncQueryStringActionFilter : IAsyncQueryStringActionFilter { private readonly IQueryStringReader _queryStringReader; public AsyncQueryStringActionFilter(IQueryStringReader queryStringReader) { - ArgumentGuard.NotNull(queryStringReader, nameof(queryStringReader)); + ArgumentNullException.ThrowIfNull(queryStringReader); _queryStringReader = queryStringReader; } @@ -20,8 +20,8 @@ public AsyncQueryStringActionFilter(IQueryStringReader queryStringReader) /// public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { - ArgumentGuard.NotNull(context, nameof(context)); - ArgumentGuard.NotNull(next, nameof(next)); + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(next); if (context.HttpContext.IsJsonApiRequest()) { diff --git a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs index ea1d67743d..44e1b384ef 100644 --- a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs +++ b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs @@ -8,17 +8,17 @@ namespace JsonApiDotNetCore.Middleware; -/// +/// [PublicAPI] -public class ExceptionHandler : IExceptionHandler +public partial class ExceptionHandler : IExceptionHandler { private readonly IJsonApiOptions _options; private readonly ILogger _logger; public ExceptionHandler(ILoggerFactory loggerFactory, IJsonApiOptions options) { - ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); - ArgumentGuard.NotNull(options, nameof(options)); + ArgumentNullException.ThrowIfNull(loggerFactory); + ArgumentNullException.ThrowIfNull(options); _options = options; _logger = loggerFactory.CreateLogger(); @@ -26,7 +26,7 @@ public ExceptionHandler(ILoggerFactory loggerFactory, IJsonApiOptions options) public IReadOnlyList HandleException(Exception exception) { - ArgumentGuard.NotNull(exception, nameof(exception)); + ArgumentNullException.ThrowIfNull(exception); Exception demystified = exception.Demystify(); @@ -40,12 +40,12 @@ private void LogException(Exception exception) LogLevel level = GetLogLevel(exception); string message = GetLogMessage(exception); - _logger.Log(level, exception, message); + LogException(level, exception, message); } protected virtual LogLevel GetLogLevel(Exception exception) { - ArgumentGuard.NotNull(exception, nameof(exception)); + ArgumentNullException.ThrowIfNull(exception); if (exception is OperationCanceledException) { @@ -62,24 +62,34 @@ protected virtual LogLevel GetLogLevel(Exception exception) protected virtual string GetLogMessage(Exception exception) { - ArgumentGuard.NotNull(exception, nameof(exception)); + ArgumentNullException.ThrowIfNull(exception); return exception is JsonApiException jsonApiException ? jsonApiException.GetSummary() : exception.Message; } protected virtual IReadOnlyList CreateErrorResponse(Exception exception) { - ArgumentGuard.NotNull(exception, nameof(exception)); + ArgumentNullException.ThrowIfNull(exception); - IReadOnlyList errors = exception is JsonApiException jsonApiException ? jsonApiException.Errors : - exception is OperationCanceledException ? new ErrorObject((HttpStatusCode)499) + IReadOnlyList errors = exception switch + { + JsonApiException jsonApiException => jsonApiException.Errors, + OperationCanceledException => new[] { - Title = "Request execution was canceled." - }.AsArray() : new ErrorObject(HttpStatusCode.InternalServerError) + new ErrorObject((HttpStatusCode)499) + { + Title = "Request execution was canceled." + } + }.AsReadOnly(), + _ => new[] { - Title = "An unhandled error occurred while processing this request.", - Detail = exception.Message - }.AsArray(); + new ErrorObject(HttpStatusCode.InternalServerError) + { + Title = "An unhandled error occurred while processing this request.", + Detail = exception.Message + } + }.AsReadOnly() + }; if (_options.IncludeExceptionStackTraceInErrors && exception is not InvalidModelStateException) { @@ -93,7 +103,7 @@ private void IncludeStackTraces(Exception exception, IReadOnlyList { string[] stackTraceLines = exception.ToString().Split(Environment.NewLine); - if (stackTraceLines.Any()) + if (stackTraceLines.Length > 0) { foreach (ErrorObject error in errors) { @@ -102,4 +112,7 @@ private void IncludeStackTraces(Exception exception, IReadOnlyList } } } + + [LoggerMessage(Message = "{Message}")] + private partial void LogException(LogLevel level, Exception exception, string message); } diff --git a/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs b/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs index 43a5989d59..05ddc69a6b 100644 --- a/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs +++ b/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs @@ -7,6 +7,12 @@ namespace JsonApiDotNetCore.Middleware; [PublicAPI] public static class HeaderConstants { + [Obsolete($"Use {nameof(JsonApiMediaType)}.{nameof(JsonApiMediaType.Default)}.ToString() instead.")] public const string MediaType = "application/vnd.api+json"; - public const string AtomicOperationsMediaType = MediaType + "; ext=\"https://jsonapi.org/ext/atomic\""; + + [Obsolete($"Use {nameof(JsonApiMediaType)}.{nameof(JsonApiMediaType.AtomicOperations)}.ToString() instead.")] + public const string AtomicOperationsMediaType = $"{MediaType}; ext=\"https://jsonapi.org/ext/atomic\""; + + [Obsolete($"Use {nameof(JsonApiMediaType)}.{nameof(JsonApiMediaType.RelaxedAtomicOperations)}.ToString() instead.")] + public const string RelaxedAtomicOperationsMediaType = $"{MediaType}; ext=atomic"; } diff --git a/src/JsonApiDotNetCore/Middleware/HttpContextExtensions.cs b/src/JsonApiDotNetCore/Middleware/HttpContextExtensions.cs index b6785e8198..85de6e92fb 100644 --- a/src/JsonApiDotNetCore/Middleware/HttpContextExtensions.cs +++ b/src/JsonApiDotNetCore/Middleware/HttpContextExtensions.cs @@ -13,7 +13,7 @@ public static class HttpContextExtensions /// public static bool IsJsonApiRequest(this HttpContext httpContext) { - ArgumentGuard.NotNull(httpContext, nameof(httpContext)); + ArgumentNullException.ThrowIfNull(httpContext); string? value = httpContext.Items[IsJsonApiRequestKey] as string; return value == bool.TrueString; @@ -21,7 +21,7 @@ public static bool IsJsonApiRequest(this HttpContext httpContext) internal static void RegisterJsonApiRequest(this HttpContext httpContext) { - ArgumentGuard.NotNull(httpContext, nameof(httpContext)); + ArgumentNullException.ThrowIfNull(httpContext); httpContext.Items[IsJsonApiRequestKey] = bool.TrueString; } diff --git a/src/JsonApiDotNetCore/Middleware/HttpMethodAttributeExtensions.cs b/src/JsonApiDotNetCore/Middleware/HttpMethodAttributeExtensions.cs new file mode 100644 index 0000000000..b85e2f53ae --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/HttpMethodAttributeExtensions.cs @@ -0,0 +1,56 @@ +using JsonApiDotNetCore.Controllers; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; + +namespace JsonApiDotNetCore.Middleware; + +internal static class HttpMethodAttributeExtensions +{ + private const string IdTemplate = "{id}"; + private const string RelationshipNameTemplate = "{relationshipName}"; + private const string SecondaryEndpointTemplate = $"{IdTemplate}/{RelationshipNameTemplate}"; + private const string RelationshipEndpointTemplate = $"{IdTemplate}/relationships/{RelationshipNameTemplate}"; + + public static JsonApiEndpoints GetJsonApiEndpoint(this IEnumerable httpMethods) + { + ArgumentNullException.ThrowIfNull(httpMethods); + + HttpMethodAttribute[] nonHeadAttributes = httpMethods.Where(attribute => attribute is not HttpHeadAttribute).ToArray(); + + return nonHeadAttributes.Length == 1 ? ResolveJsonApiEndpoint(nonHeadAttributes[0]) : JsonApiEndpoints.None; + } + + private static JsonApiEndpoints ResolveJsonApiEndpoint(HttpMethodAttribute httpMethod) + { + return httpMethod switch + { + HttpGetAttribute httpGet => httpGet.Template switch + { + null => JsonApiEndpoints.GetCollection, + IdTemplate => JsonApiEndpoints.GetSingle, + SecondaryEndpointTemplate => JsonApiEndpoints.GetSecondary, + RelationshipEndpointTemplate => JsonApiEndpoints.GetRelationship, + _ => JsonApiEndpoints.None + }, + HttpPostAttribute httpPost => httpPost.Template switch + { + null => JsonApiEndpoints.Post, + RelationshipEndpointTemplate => JsonApiEndpoints.PostRelationship, + _ => JsonApiEndpoints.None + }, + HttpPatchAttribute httpPatch => httpPatch.Template switch + { + IdTemplate => JsonApiEndpoints.Patch, + RelationshipEndpointTemplate => JsonApiEndpoints.PatchRelationship, + _ => JsonApiEndpoints.None + }, + HttpDeleteAttribute httpDelete => httpDelete.Template switch + { + IdTemplate => JsonApiEndpoints.Delete, + RelationshipEndpointTemplate => JsonApiEndpoints.DeleteRelationship, + _ => JsonApiEndpoints.None + }, + _ => JsonApiEndpoints.None + }; + } +} diff --git a/src/JsonApiDotNetCore/Middleware/IAsyncConvertEmptyActionResultFilter.cs b/src/JsonApiDotNetCore/Middleware/IAsyncConvertEmptyActionResultFilter.cs index 87676657e5..3116b45d40 100644 --- a/src/JsonApiDotNetCore/Middleware/IAsyncConvertEmptyActionResultFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/IAsyncConvertEmptyActionResultFilter.cs @@ -14,6 +14,4 @@ namespace JsonApiDotNetCore.Middleware; /// https://github.com/dotnet/aspnetcore/issues/16969 /// [PublicAPI] -public interface IAsyncConvertEmptyActionResultFilter : IAsyncAlwaysRunResultFilter -{ -} +public interface IAsyncConvertEmptyActionResultFilter : IAsyncAlwaysRunResultFilter; diff --git a/src/JsonApiDotNetCore/Middleware/IAsyncJsonApiExceptionFilter.cs b/src/JsonApiDotNetCore/Middleware/IAsyncJsonApiExceptionFilter.cs index fb0cbb9b17..1fc4e136af 100644 --- a/src/JsonApiDotNetCore/Middleware/IAsyncJsonApiExceptionFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/IAsyncJsonApiExceptionFilter.cs @@ -7,6 +7,4 @@ namespace JsonApiDotNetCore.Middleware; /// Application-wide exception filter that invokes for JSON:API requests. /// [PublicAPI] -public interface IAsyncJsonApiExceptionFilter : IAsyncExceptionFilter -{ -} +public interface IAsyncJsonApiExceptionFilter : IAsyncExceptionFilter; diff --git a/src/JsonApiDotNetCore/Middleware/IAsyncQueryStringActionFilter.cs b/src/JsonApiDotNetCore/Middleware/IAsyncQueryStringActionFilter.cs index 0c9cbfbb29..d3df469f64 100644 --- a/src/JsonApiDotNetCore/Middleware/IAsyncQueryStringActionFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/IAsyncQueryStringActionFilter.cs @@ -7,6 +7,4 @@ namespace JsonApiDotNetCore.Middleware; /// Application-wide entry point for processing JSON:API request query strings. /// [PublicAPI] -public interface IAsyncQueryStringActionFilter : IAsyncActionFilter -{ -} +public interface IAsyncQueryStringActionFilter : IAsyncActionFilter; diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiContentNegotiator.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiContentNegotiator.cs new file mode 100644 index 0000000000..f80c910266 --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiContentNegotiator.cs @@ -0,0 +1,16 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; + +namespace JsonApiDotNetCore.Middleware; + +/// +/// Performs content negotiation for JSON:API requests. +/// +public interface IJsonApiContentNegotiator +{ + /// + /// Validates the Content-Type and Accept HTTP headers from the incoming request. Throws a if unsupported. Otherwise, + /// returns the list of negotiated JSON:API extensions, which should always be a subset of . + /// + IReadOnlySet Negotiate(); +} diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiEndpointFilter.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiEndpointFilter.cs new file mode 100644 index 0000000000..6dbf81bce3 --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiEndpointFilter.cs @@ -0,0 +1,24 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; + +namespace JsonApiDotNetCore.Middleware; + +/// +/// Enables to remove JSON:API controller action methods at startup. For atomic:operation requests, see . +/// +[PublicAPI] +public interface IJsonApiEndpointFilter +{ + /// + /// Determines whether to remove the associated controller action method. + /// + /// + /// The primary resource type of the endpoint. + /// + /// + /// The JSON:API endpoint. Despite being a enum, a single value is always passed here. + /// + bool IsEnabled(ResourceType resourceType, JsonApiEndpoints endpoint); +} diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiInputFormatter.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiInputFormatter.cs index cb5fe76167..7879530650 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiInputFormatter.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiInputFormatter.cs @@ -7,6 +7,4 @@ namespace JsonApiDotNetCore.Middleware; /// Application-wide entry point for reading JSON:API request bodies. /// [PublicAPI] -public interface IJsonApiInputFormatter : IInputFormatter -{ -} +public interface IJsonApiInputFormatter : IInputFormatter; diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiOutputFormatter.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiOutputFormatter.cs index bc7213ebed..ff285e26e7 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiOutputFormatter.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiOutputFormatter.cs @@ -7,6 +7,4 @@ namespace JsonApiDotNetCore.Middleware; /// Application-wide entry point for writing JSON:API response bodies. /// [PublicAPI] -public interface IJsonApiOutputFormatter : IOutputFormatter -{ -} +public interface IJsonApiOutputFormatter : IOutputFormatter; diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs index 1d66bf517f..959c89d66f 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs @@ -60,6 +60,11 @@ public interface IJsonApiRequest /// string? TransactionId { get; } + /// + /// The JSON:API extensions enabled for the current request. This is always a subset of . + /// + IReadOnlySet Extensions { get; } + /// /// Performs a shallow copy. /// diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiRoutingConvention.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiRoutingConvention.cs index 86b68a9b03..a83156f33f 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiRoutingConvention.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiRoutingConvention.cs @@ -7,6 +7,4 @@ namespace JsonApiDotNetCore.Middleware; /// Service for specifying which routing convention to use. This can be overridden to customize the relation between controllers and mapped routes. /// [PublicAPI] -public interface IJsonApiRoutingConvention : IApplicationModelConvention, IControllerResourceMapping -{ -} +public interface IJsonApiRoutingConvention : IApplicationModelConvention, IControllerResourceMapping; diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiContentNegotiator.cs b/src/JsonApiDotNetCore/Middleware/JsonApiContentNegotiator.cs new file mode 100644 index 0000000000..a9806129f8 --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/JsonApiContentNegotiator.cs @@ -0,0 +1,241 @@ +using System.Net; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace JsonApiDotNetCore.Middleware; + +/// +[PublicAPI] +public class JsonApiContentNegotiator : IJsonApiContentNegotiator +{ + private readonly IJsonApiOptions _options; + private readonly IHttpContextAccessor _httpContextAccessor; + + private HttpContext HttpContext + { + get + { + if (_httpContextAccessor.HttpContext == null) + { + throw new InvalidOperationException("An active HTTP request is required."); + } + + return _httpContextAccessor.HttpContext; + } + } + + public JsonApiContentNegotiator(IJsonApiOptions options, IHttpContextAccessor httpContextAccessor) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(httpContextAccessor); + + _options = options; + _httpContextAccessor = httpContextAccessor; + } + + /// + public IReadOnlySet Negotiate() + { + IReadOnlyList possibleMediaTypes = GetPossibleMediaTypes(); + + JsonApiMediaType? requestMediaType = ValidateContentType(possibleMediaTypes); + return ValidateAcceptHeader(possibleMediaTypes, requestMediaType); + } + + private JsonApiMediaType? ValidateContentType(IReadOnlyList possibleMediaTypes) + { + if (HttpContext.Request.ContentType == null) + { + if (HttpContext.Request.ContentLength > 0) + { + throw CreateContentTypeError(possibleMediaTypes); + } + + return null; + } + + JsonApiMediaType? mediaType = JsonApiMediaType.TryParseContentTypeHeaderValue(HttpContext.Request.ContentType); + + if (mediaType == null || !possibleMediaTypes.Contains(mediaType)) + { + throw CreateContentTypeError(possibleMediaTypes); + } + + return mediaType; + } + + private IReadOnlySet ValidateAcceptHeader(IReadOnlyList possibleMediaTypes, JsonApiMediaType? requestMediaType) + { + string[] acceptHeaderValues = HttpContext.Request.Headers.GetCommaSeparatedValues("Accept"); + JsonApiMediaType? bestMatch = null; + + if (acceptHeaderValues.Length == 0) + { + bestMatch = GetDefaultMediaType(possibleMediaTypes, requestMediaType); + } + else + { + decimal bestQualityFactor = 0m; + + foreach (string acceptHeaderValue in acceptHeaderValues) + { + (JsonApiMediaType MediaType, decimal QualityFactor)? result = JsonApiMediaType.TryParseAcceptHeaderValue(acceptHeaderValue); + + if (result != null) + { + if (result.Value.MediaType.Equals(requestMediaType) && possibleMediaTypes.Contains(requestMediaType)) + { + // Content-Type always wins over other candidates, because JsonApiDotNetCore doesn't support + // different extension sets for the request and response body. + bestMatch = requestMediaType; + break; + } + + bool isBetterMatch = false; + int? currentIndex = null; + + if (bestMatch == null) + { + isBetterMatch = true; + } + else if (result.Value.QualityFactor > bestQualityFactor) + { + isBetterMatch = true; + } + else if (result.Value.QualityFactor == bestQualityFactor) + { + if (result.Value.MediaType.Extensions.Count > bestMatch.Extensions.Count) + { + isBetterMatch = true; + } + else if (result.Value.MediaType.Extensions.Count == bestMatch.Extensions.Count) + { + int bestIndex = possibleMediaTypes.FindIndex(bestMatch); + currentIndex = possibleMediaTypes.FindIndex(result.Value.MediaType); + + if (currentIndex != -1 && currentIndex < bestIndex) + { + isBetterMatch = true; + } + } + } + + if (isBetterMatch) + { + bool existsInPossibleMediaTypes = currentIndex >= 0 || possibleMediaTypes.Contains(result.Value.MediaType); + + if (existsInPossibleMediaTypes) + { + bestMatch = result.Value.MediaType; + bestQualityFactor = result.Value.QualityFactor; + } + } + } + } + } + + if (bestMatch == null) + { + throw CreateAcceptHeaderError(possibleMediaTypes); + } + + if (requestMediaType != null && !bestMatch.Equals(requestMediaType)) + { + throw CreateAcceptHeaderError(possibleMediaTypes); + } + + return bestMatch.Extensions; + } + + /// + /// Returns the JSON:API media type (possibly including extensions) to use when no Accept header was sent. + /// + /// + /// The media types returned from . + /// + /// + /// The media type from in the Content-Type header. + /// + /// + /// The default media type to use, or null if not available. + /// + protected virtual JsonApiMediaType? GetDefaultMediaType(IReadOnlyList possibleMediaTypes, JsonApiMediaType? requestMediaType) + { + return possibleMediaTypes.Contains(JsonApiMediaType.Default) ? JsonApiMediaType.Default : null; + } + + /// + /// Gets the list of possible combinations of JSON:API extensions that are available at the current endpoint. The set of extensions in the request body + /// must always be the same as in the response body. + /// + /// + /// Override this method to add support for custom JSON:API extensions. Implementations should take into + /// account. During content negotiation, the first compatible entry with the highest number of extensions is preferred, but beware that clients can + /// overrule this using quality factors in an Accept header. + /// + protected virtual IReadOnlyList GetPossibleMediaTypes() + { + List mediaTypes = []; + + // Relaxed entries come after JSON:API compliant entries, which makes them less likely to be selected. + + if (IsOperationsEndpoint()) + { + if (_options.Extensions.Contains(JsonApiMediaTypeExtension.AtomicOperations)) + { + mediaTypes.Add(JsonApiMediaType.AtomicOperations); + } + + if (_options.Extensions.Contains(JsonApiMediaTypeExtension.RelaxedAtomicOperations)) + { + mediaTypes.Add(JsonApiMediaType.RelaxedAtomicOperations); + } + } + else + { + mediaTypes.Add(JsonApiMediaType.Default); + } + + return mediaTypes.AsReadOnly(); + } + + protected bool IsOperationsEndpoint() + { + RouteValueDictionary routeValues = HttpContext.GetRouteData().Values; + return JsonApiMiddleware.IsRouteForOperations(routeValues); + } + + private JsonApiException CreateContentTypeError(IReadOnlyList possibleMediaTypes) + { + string allowedValues = string.Join(" or ", possibleMediaTypes.Select(mediaType => $"'{mediaType}'")); + + return new JsonApiException(new ErrorObject(HttpStatusCode.UnsupportedMediaType) + { + Title = "The specified Content-Type header value is not supported.", + Detail = $"Use {allowedValues} instead of '{HttpContext.Request.ContentType}' for the Content-Type header value.", + Source = new ErrorSource + { + Header = "Content-Type" + } + }); + } + + private static JsonApiException CreateAcceptHeaderError(IReadOnlyList possibleMediaTypes) + { + string allowedValues = string.Join(" or ", possibleMediaTypes.Select(mediaType => $"'{mediaType}'")); + + return new JsonApiException(new ErrorObject(HttpStatusCode.NotAcceptable) + { + Title = "The specified Accept header value does not contain any supported media types.", + Detail = $"Include {allowedValues} in the Accept header values.", + Source = new ErrorSource + { + Header = "Accept" + } + }); + } +} diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs b/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs index 077f0573f0..008d5fd6dd 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs @@ -4,13 +4,13 @@ namespace JsonApiDotNetCore.Middleware; -/// +/// public sealed class JsonApiInputFormatter : IJsonApiInputFormatter { /// public bool CanRead(InputFormatterContext context) { - ArgumentGuard.NotNull(context, nameof(context)); + ArgumentNullException.ThrowIfNull(context); return context.HttpContext.IsJsonApiRequest(); } @@ -18,7 +18,7 @@ public bool CanRead(InputFormatterContext context) /// public async Task ReadAsync(InputFormatterContext context) { - ArgumentGuard.NotNull(context, nameof(context)); + ArgumentNullException.ThrowIfNull(context); var reader = context.HttpContext.RequestServices.GetRequiredService(); diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiMediaType.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMediaType.cs new file mode 100644 index 0000000000..cfb2cb8b07 --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMediaType.cs @@ -0,0 +1,188 @@ +using JetBrains.Annotations; +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; + +namespace JsonApiDotNetCore.Middleware; + +/// +/// Represents the JSON:API media type (application/vnd.api+json) with an optional set of extensions. +/// +[PublicAPI] +public sealed class JsonApiMediaType : IEquatable +{ + private static readonly StringSegment BaseMediaTypeSegment = new("application/vnd.api+json"); + private static readonly StringSegment ExtSegment = new("ext"); + private static readonly StringSegment QualitySegment = new("q"); + + /// + /// Gets the JSON:API media type without any extensions. + /// + public static readonly JsonApiMediaType Default = new([]); + + /// + /// Gets the JSON:API media type with the "https://jsonapi.org/ext/atomic" extension. + /// + public static readonly JsonApiMediaType AtomicOperations = new([JsonApiMediaTypeExtension.AtomicOperations]); + + /// + /// Gets the JSON:API media type with the "atomic" extension. + /// + public static readonly JsonApiMediaType RelaxedAtomicOperations = new([JsonApiMediaTypeExtension.RelaxedAtomicOperations]); + + public IReadOnlySet Extensions { get; } + + public JsonApiMediaType(IReadOnlySet extensions) + { + ArgumentNullException.ThrowIfNull(extensions); + + Extensions = extensions; + } + + public JsonApiMediaType(IEnumerable extensions) + { + ArgumentNullException.ThrowIfNull(extensions); + + Extensions = extensions.ToHashSet().AsReadOnly(); + } + + internal static JsonApiMediaType? TryParseContentTypeHeaderValue(string value) + { + (JsonApiMediaType MediaType, decimal QualityFactor)? result = TryParse(value, false, false); + return result?.MediaType; + } + + internal static (JsonApiMediaType MediaType, decimal QualityFactor)? TryParseAcceptHeaderValue(string value) + { + return TryParse(value, true, true); + } + + private static (JsonApiMediaType MediaType, decimal QualityFactor)? TryParse(string value, bool allowSuperset, bool allowQualityFactor) + { + // Parameter names are case-insensitive, according to https://datatracker.ietf.org/doc/html/rfc7231#section-3.1.1.1. + // But JSON:API doesn't define case-insensitive for the "ext" parameter value. + + if (MediaTypeHeaderValue.TryParse(value, out MediaTypeHeaderValue? headerValue)) + { + bool isBaseMatch = allowSuperset + ? headerValue.MatchesMediaType(BaseMediaTypeSegment) + : BaseMediaTypeSegment.Equals(headerValue.MediaType, StringComparison.OrdinalIgnoreCase); + + if (isBaseMatch) + { + HashSet extensions = []; + + decimal qualityFactor = 1.0m; + + foreach (NameValueHeaderValue parameter in headerValue.Parameters) + { + if (allowQualityFactor && parameter.Name.Equals(QualitySegment, StringComparison.OrdinalIgnoreCase) && + decimal.TryParse(parameter.Value, out decimal qualityValue)) + { + qualityFactor = qualityValue; + continue; + } + + if (!parameter.Name.Equals(ExtSegment, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + ParseExtensions(parameter, extensions); + } + + return (new JsonApiMediaType(extensions), qualityFactor); + } + } + + return null; + } + + private static void ParseExtensions(NameValueHeaderValue parameter, HashSet extensions) + { + string parameterValue = parameter.GetUnescapedValue().ToString(); + + foreach (string extValue in parameterValue.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var extension = new JsonApiMediaTypeExtension(extValue); + extensions.Add(extension); + } + } + + public override string ToString() + { + var baseHeaderValue = new MediaTypeHeaderValue(BaseMediaTypeSegment); + List parameters = []; + bool requiresEscape = false; + + foreach (JsonApiMediaTypeExtension extension in Extensions) + { + var extHeaderValue = new NameValueHeaderValue(ExtSegment); + extHeaderValue.SetAndEscapeValue(extension.UnescapedValue); + + if (extHeaderValue.Value != extension.UnescapedValue) + { + requiresEscape = true; + } + + parameters.Add(extHeaderValue); + } + + if (parameters.Count == 1) + { + baseHeaderValue.Parameters.Add(parameters[0]); + } + else if (parameters.Count > 1) + { + if (requiresEscape) + { + // JSON:API requires all 'ext' parameters combined into a single space-separated value. + string compositeValue = string.Join(' ', parameters.Select(parameter => parameter.GetUnescapedValue().ToString())); + var compositeParameter = new NameValueHeaderValue(ExtSegment); + compositeParameter.SetAndEscapeValue(compositeValue); + baseHeaderValue.Parameters.Add(compositeParameter); + } + else + { + // Relaxed mode: use separate 'ext' parameters. + foreach (NameValueHeaderValue parameter in parameters) + { + baseHeaderValue.Parameters.Add(parameter); + } + } + } + + return baseHeaderValue.ToString(); + } + + public bool Equals(JsonApiMediaType? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return Extensions.SetEquals(other.Extensions); + } + + public override bool Equals(object? other) + { + return Equals(other as JsonApiMediaType); + } + + public override int GetHashCode() + { + int hashCode = 0; + + foreach (JsonApiMediaTypeExtension extension in Extensions) + { + hashCode = HashCode.Combine(hashCode, extension); + } + + return hashCode; + } +} diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiMediaTypeExtension.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMediaTypeExtension.cs new file mode 100644 index 0000000000..6ee7ac10ce --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMediaTypeExtension.cs @@ -0,0 +1,52 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Middleware; + +/// +/// Represents a JSON:API extension (in unescaped format), which occurs as an "ext" parameter inside an HTTP Accept or Content-Type header. +/// +[PublicAPI] +public sealed class JsonApiMediaTypeExtension : IEquatable +{ + public static readonly JsonApiMediaTypeExtension AtomicOperations = new("https://jsonapi.org/ext/atomic"); + public static readonly JsonApiMediaTypeExtension RelaxedAtomicOperations = new("atomic"); + + public string UnescapedValue { get; } + + public JsonApiMediaTypeExtension(string unescapedValue) + { + ArgumentException.ThrowIfNullOrEmpty(unescapedValue); + + UnescapedValue = unescapedValue; + } + + public override string ToString() + { + return UnescapedValue; + } + + public bool Equals(JsonApiMediaTypeExtension? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return UnescapedValue == other.UnescapedValue; + } + + public override bool Equals(object? other) + { + return Equals(other as JsonApiMediaTypeExtension); + } + + public override int GetHashCode() + { + return UnescapedValue.GetHashCode(); + } +} diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs index 94507750da..2084676c8a 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs @@ -3,6 +3,7 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Diagnostics; +using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Http; @@ -18,84 +19,96 @@ namespace JsonApiDotNetCore.Middleware; /// Intercepts HTTP requests to populate injected instance for JSON:API requests. /// [PublicAPI] -public sealed class JsonApiMiddleware +public sealed partial class JsonApiMiddleware { - private static readonly MediaTypeHeaderValue MediaType = MediaTypeHeaderValue.Parse(HeaderConstants.MediaType); - private static readonly MediaTypeHeaderValue AtomicOperationsMediaType = MediaTypeHeaderValue.Parse(HeaderConstants.AtomicOperationsMediaType); - - private readonly RequestDelegate _next; - - public JsonApiMiddleware(RequestDelegate next, IHttpContextAccessor httpContextAccessor) + private readonly RequestDelegate? _next; + private readonly IControllerResourceMapping _controllerResourceMapping; + private readonly IJsonApiOptions _options; + private readonly IJsonApiContentNegotiator _contentNegotiator; + private readonly ILogger _logger; + + public JsonApiMiddleware(RequestDelegate? next, IHttpContextAccessor httpContextAccessor, IControllerResourceMapping controllerResourceMapping, + IJsonApiOptions options, IJsonApiContentNegotiator contentNegotiator, ILogger logger) { + ArgumentNullException.ThrowIfNull(httpContextAccessor); + ArgumentNullException.ThrowIfNull(controllerResourceMapping); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(contentNegotiator); + ArgumentNullException.ThrowIfNull(logger); + _next = next; + _controllerResourceMapping = controllerResourceMapping; + _options = options; + _contentNegotiator = contentNegotiator; + _logger = logger; +#pragma warning disable CA2000 // Dispose objects before losing scope var session = new AspNetCodeTimerSession(httpContextAccessor); +#pragma warning restore CA2000 // Dispose objects before losing scope CodeTimingSessionManager.Capture(session); } - public async Task InvokeAsync(HttpContext httpContext, IControllerResourceMapping controllerResourceMapping, IJsonApiOptions options, - IJsonApiRequest request, ILogger logger) + public async Task InvokeAsync(HttpContext httpContext, IJsonApiRequest request) { - ArgumentGuard.NotNull(httpContext, nameof(httpContext)); - ArgumentGuard.NotNull(controllerResourceMapping, nameof(controllerResourceMapping)); - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(logger, nameof(logger)); + ArgumentNullException.ThrowIfNull(httpContext); + ArgumentNullException.ThrowIfNull(request); using (CodeTimingSessionManager.Current.Measure("JSON:API middleware")) { - if (!await ValidateIfMatchHeaderAsync(httpContext, options.SerializerWriteOptions)) - { - return; - } - RouteValueDictionary routeValues = httpContext.GetRouteData().Values; - ResourceType? primaryResourceType = CreatePrimaryResourceType(httpContext, controllerResourceMapping); + ResourceType? primaryResourceType = CreatePrimaryResourceType(httpContext, _controllerResourceMapping); - if (primaryResourceType != null) + bool isResourceRequest = primaryResourceType != null; + bool isOperationsRequest = IsRouteForOperations(routeValues); + + if (isResourceRequest || isOperationsRequest) { - if (!await ValidateContentTypeHeaderAsync(HeaderConstants.MediaType, httpContext, options.SerializerWriteOptions) || - !await ValidateAcceptHeaderAsync(MediaType, httpContext, options.SerializerWriteOptions)) + try { - return; + ValidateIfMatchHeader(httpContext.Request); + IReadOnlySet extensions = _contentNegotiator.Negotiate(); + + if (isResourceRequest) + { + SetupResourceRequest((JsonApiRequest)request, primaryResourceType!, routeValues, httpContext.Request, extensions); + } + else + { + SetupOperationsRequest((JsonApiRequest)request, extensions); + } + + httpContext.RegisterJsonApiRequest(); } - - SetupResourceRequest((JsonApiRequest)request, primaryResourceType, routeValues, httpContext.Request); - - httpContext.RegisterJsonApiRequest(); - } - else if (IsRouteForOperations(routeValues)) - { - if (!await ValidateContentTypeHeaderAsync(HeaderConstants.AtomicOperationsMediaType, httpContext, options.SerializerWriteOptions) || - !await ValidateAcceptHeaderAsync(AtomicOperationsMediaType, httpContext, options.SerializerWriteOptions)) + catch (JsonApiException exception) { + await FlushResponseAsync(httpContext.Response, _options.SerializerWriteOptions, exception); return; } - - SetupOperationsRequest((JsonApiRequest)request, options, httpContext.Request); - - httpContext.RegisterJsonApiRequest(); } - using (CodeTimingSessionManager.Current.Measure("Subsequent middleware")) + if (_next != null) { - await _next(httpContext); + using (CodeTimingSessionManager.Current.Measure("Subsequent middleware")) + { + await _next(httpContext); + } } } - if (CodeTimingSessionManager.IsEnabled) + if (CodeTimingSessionManager.IsEnabled && _logger.IsEnabled(LogLevel.Information)) { string timingResults = CodeTimingSessionManager.Current.GetResults(); - string url = httpContext.Request.GetDisplayUrl(); - logger.LogInformation($"Measurement results for {httpContext.Request.Method} {url}:{Environment.NewLine}{timingResults}"); + string requestMethod = httpContext.Request.Method.Replace(Environment.NewLine, ""); + string requestUrl = httpContext.Request.GetEncodedUrl(); + LogMeasurement(requestMethod, requestUrl, Environment.NewLine, timingResults); } } - private async Task ValidateIfMatchHeaderAsync(HttpContext httpContext, JsonSerializerOptions serializerOptions) + private void ValidateIfMatchHeader(HttpRequest httpRequest) { - if (httpContext.Request.Headers.ContainsKey(HeaderNames.IfMatch)) + if (httpRequest.Headers.ContainsKey(HeaderNames.IfMatch)) { - await FlushResponseAsync(httpContext.Response, serializerOptions, new ErrorObject(HttpStatusCode.PreconditionFailed) + throw new JsonApiException(new ErrorObject(HttpStatusCode.PreconditionFailed) { Title = "Detection of mid-air edit collisions using ETags is not supported.", Source = new ErrorSource @@ -103,11 +116,7 @@ private async Task ValidateIfMatchHeaderAsync(HttpContext httpContext, Jso Header = "If-Match" } }); - - return false; } - - return true; } private static ResourceType? CreatePrimaryResourceType(HttpContext httpContext, IControllerResourceMapping controllerResourceMapping) @@ -120,95 +129,11 @@ private async Task ValidateIfMatchHeaderAsync(HttpContext httpContext, Jso : null; } - private static async Task ValidateContentTypeHeaderAsync(string allowedContentType, HttpContext httpContext, JsonSerializerOptions serializerOptions) - { - string? contentType = httpContext.Request.ContentType; - - if (contentType != null && contentType != allowedContentType) - { - await FlushResponseAsync(httpContext.Response, serializerOptions, new ErrorObject(HttpStatusCode.UnsupportedMediaType) - { - Title = "The specified Content-Type header value is not supported.", - Detail = $"Please specify '{allowedContentType}' instead of '{contentType}' for the Content-Type header value.", - Source = new ErrorSource - { - Header = "Content-Type" - } - }); - - return false; - } - - return true; - } - - private static async Task ValidateAcceptHeaderAsync(MediaTypeHeaderValue allowedMediaTypeValue, HttpContext httpContext, - JsonSerializerOptions serializerOptions) - { - string[] acceptHeaders = httpContext.Request.Headers.GetCommaSeparatedValues("Accept"); - - if (!acceptHeaders.Any()) - { - return true; - } - - bool seenCompatibleMediaType = false; - - foreach (string acceptHeader in acceptHeaders) - { - if (MediaTypeHeaderValue.TryParse(acceptHeader, out MediaTypeHeaderValue? headerValue)) - { - headerValue.Quality = null; - - if (headerValue.MediaType == "*/*" || headerValue.MediaType == "application/*") - { - seenCompatibleMediaType = true; - break; - } - - if (allowedMediaTypeValue.Equals(headerValue)) - { - seenCompatibleMediaType = true; - break; - } - } - } - - if (!seenCompatibleMediaType) - { - await FlushResponseAsync(httpContext.Response, serializerOptions, new ErrorObject(HttpStatusCode.NotAcceptable) - { - Title = "The specified Accept header value does not contain any supported media types.", - Detail = $"Please include '{allowedMediaTypeValue}' in the Accept header values.", - Source = new ErrorSource - { - Header = "Accept" - } - }); - - return false; - } - - return true; - } - - private static async Task FlushResponseAsync(HttpResponse httpResponse, JsonSerializerOptions serializerOptions, ErrorObject error) - { - httpResponse.ContentType = HeaderConstants.MediaType; - httpResponse.StatusCode = (int)error.StatusCode; - - var errorDocument = new Document - { - Errors = error.AsList() - }; - - await JsonSerializer.SerializeAsync(httpResponse.Body, errorDocument, serializerOptions); - await httpResponse.Body.FlushAsync(); - } - private static void SetupResourceRequest(JsonApiRequest request, ResourceType primaryResourceType, RouteValueDictionary routeValues, - HttpRequest httpRequest) + HttpRequest httpRequest, IReadOnlySet extensions) { + AssertNoAtomicOperationsExtension(extensions); + request.IsReadOnly = httpRequest.Method == HttpMethod.Get.Method || httpRequest.Method == HttpMethod.Head.Method; request.PrimaryResourceType = primaryResourceType; request.PrimaryId = GetPrimaryRequestId(routeValues); @@ -256,6 +181,15 @@ private static void SetupResourceRequest(JsonApiRequest request, ResourceType pr bool isGetAll = request.PrimaryId == null && request.IsReadOnly; request.IsCollection = isGetAll || request.Relationship is HasManyAttribute; + request.Extensions = extensions; + } + + private static void AssertNoAtomicOperationsExtension(IReadOnlySet extensions) + { + if (extensions.Contains(JsonApiMediaTypeExtension.AtomicOperations) || extensions.Contains(JsonApiMediaTypeExtension.RelaxedAtomicOperations)) + { + throw new InvalidOperationException("Incorrect content negotiation implementation detected: Unexpected atomic:operations extension found."); + } } private static string? GetPrimaryRequestId(RouteValueDictionary routeValues) @@ -274,15 +208,44 @@ private static bool IsRouteForRelationship(RouteValueDictionary routeValues) return actionName.EndsWith("Relationship", StringComparison.Ordinal); } - private static bool IsRouteForOperations(RouteValueDictionary routeValues) + internal static bool IsRouteForOperations(RouteValueDictionary routeValues) { string actionName = (string)routeValues["action"]!; return actionName == "PostOperations"; } - private static void SetupOperationsRequest(JsonApiRequest request, IJsonApiOptions options, HttpRequest httpRequest) + private static void SetupOperationsRequest(JsonApiRequest request, IReadOnlySet extensions) { + AssertHasAtomicOperationsExtension(extensions); + request.IsReadOnly = false; request.Kind = EndpointKind.AtomicOperations; + request.Extensions = extensions; + } + + private static void AssertHasAtomicOperationsExtension(IReadOnlySet extensions) + { + if (!extensions.Contains(JsonApiMediaTypeExtension.AtomicOperations) && !extensions.Contains(JsonApiMediaTypeExtension.RelaxedAtomicOperations)) + { + throw new InvalidOperationException("Incorrect content negotiation implementation detected: Missing atomic:operations extension."); + } } + + private static async Task FlushResponseAsync(HttpResponse httpResponse, JsonSerializerOptions serializerOptions, JsonApiException exception) + { + httpResponse.ContentType = JsonApiMediaType.Default.ToString(); + httpResponse.StatusCode = (int)ErrorObject.GetResponseStatusCode(exception.Errors); + + var errorDocument = new Document + { + Errors = exception.Errors.ToList() + }; + + await JsonSerializer.SerializeAsync(httpResponse.Body, errorDocument, serializerOptions); + await httpResponse.Body.FlushAsync(); + } + + [LoggerMessage(Level = LogLevel.Information, SkipEnabledCheck = true, + Message = "Measurement results for {RequestMethod} {RequestUrl}:{LineBreak}{TimingResults}")] + private partial void LogMeasurement(string requestMethod, string requestUrl, string lineBreak, string timingResults); } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs b/src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs index c32bb9d9f9..1f6de39b8b 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs @@ -4,13 +4,13 @@ namespace JsonApiDotNetCore.Middleware; -/// +/// public sealed class JsonApiOutputFormatter : IJsonApiOutputFormatter { /// public bool CanWriteResult(OutputFormatterCanWriteContext context) { - ArgumentGuard.NotNull(context, nameof(context)); + ArgumentNullException.ThrowIfNull(context); return context.HttpContext.IsJsonApiRequest(); } @@ -18,7 +18,7 @@ public bool CanWriteResult(OutputFormatterCanWriteContext context) /// public async Task WriteAsync(OutputFormatterWriteContext context) { - ArgumentGuard.NotNull(context, nameof(context)); + ArgumentNullException.ThrowIfNull(context); var writer = context.HttpContext.RequestServices.GetRequiredService(); await writer.WriteAsync(context.Object, context.HttpContext); diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs index a28c01fcd6..4f382ec329 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs @@ -4,10 +4,12 @@ namespace JsonApiDotNetCore.Middleware; -/// +/// [PublicAPI] public sealed class JsonApiRequest : IJsonApiRequest { + private static readonly IReadOnlySet EmptyExtensionSet = new HashSet().AsReadOnly(); + /// public EndpointKind Kind { get; set; } @@ -35,10 +37,13 @@ public sealed class JsonApiRequest : IJsonApiRequest /// public string? TransactionId { get; set; } + /// + public IReadOnlySet Extensions { get; set; } = EmptyExtensionSet; + /// public void CopyFrom(IJsonApiRequest other) { - ArgumentGuard.NotNull(other, nameof(other)); + ArgumentNullException.ThrowIfNull(other); Kind = other.Kind; PrimaryId = other.PrimaryId; @@ -49,5 +54,6 @@ public void CopyFrom(IJsonApiRequest other) IsReadOnly = other.IsReadOnly; WriteOperation = other.WriteOperation; TransactionId = other.TransactionId; + Extensions = other.Extensions; } } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs index fe95d93446..fd55d4ec5b 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs @@ -7,40 +7,49 @@ using JsonApiDotNetCore.Resources; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Middleware; /// -/// The default routing convention registers the name of the resource as the route using the serializer naming convention. The default for this is a -/// camel case formatter. If the controller directly inherits from and there is no resource directly associated, it -/// uses the name of the controller instead of the name of the type. +/// Registers routes based on the JSON:API resource name, which defaults to camel-case pluralized form of the resource CLR type name. If unavailable (for +/// example, when a controller directly inherits from ), the serializer naming convention is applied on the +/// controller type name (camel-case by default). /// /// { } // => /someResources/relationship/relatedResource +/// // controller name is ignored when resource type is available: +/// public class RandomNameController : JsonApiController { } // => /someResources /// -/// public class RandomNameController : JsonApiController { } // => /someResources/relationship/relatedResource +/// // when using kebab-case naming convention in options: +/// public class RandomNameController : JsonApiController { } // => /some-resources /// -/// // when using kebab-case naming convention: -/// public class SomeResourceController : JsonApiController { } // => /some-resources/relationship/related-resource -/// -/// public class SomeVeryCustomController : CoreJsonApiController { } // => /someVeryCustoms/relationship/relatedResource +/// // unable to determine resource type: +/// public class SomeVeryCustomController : CoreJsonApiController { } // => /someVeryCustom /// ]]> [PublicAPI] -public sealed class JsonApiRoutingConvention : IJsonApiRoutingConvention +public sealed partial class JsonApiRoutingConvention : IJsonApiRoutingConvention { private readonly IJsonApiOptions _options; private readonly IResourceGraph _resourceGraph; - private readonly Dictionary _registeredControllerNameByTemplate = new(); - private readonly Dictionary _resourceTypePerControllerTypeMap = new(); - private readonly Dictionary _controllerPerResourceTypeMap = new(); - - public JsonApiRoutingConvention(IJsonApiOptions options, IResourceGraph resourceGraph) + private readonly IJsonApiEndpointFilter _jsonApiEndpointFilter; + private readonly ILogger _logger; + private readonly Dictionary _registeredControllerNameByTemplate = []; + private readonly Dictionary _resourceTypePerControllerTypeMap = []; + private readonly Dictionary _controllerPerResourceTypeMap = []; + + public JsonApiRoutingConvention(IJsonApiOptions options, IResourceGraph resourceGraph, IJsonApiEndpointFilter jsonApiEndpointFilter, + ILogger logger) { - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(resourceGraph); + ArgumentNullException.ThrowIfNull(jsonApiEndpointFilter); + ArgumentNullException.ThrowIfNull(logger); _options = options; _resourceGraph = resourceGraph; + _jsonApiEndpointFilter = jsonApiEndpointFilter; + _logger = logger; } /// @@ -60,13 +69,29 @@ public JsonApiRoutingConvention(IJsonApiOptions options, IResourceGraph resource /// public void Apply(ApplicationModel application) { - ArgumentGuard.NotNull(application, nameof(application)); + ArgumentNullException.ThrowIfNull(application); foreach (ControllerModel controller in application.Controllers) { - bool isOperationsController = IsOperationsController(controller.ControllerType); + if (!IsJsonApiController(controller)) + { + continue; + } + + if (HasApiControllerAttribute(controller)) + { + // Although recommended by Microsoft for hard-written controllers, the opinionated behavior of [ApiController] violates the JSON:API specification. + // See https://learn.microsoft.com/en-us/aspnet/core/web-api/?view=aspnetcore-7.0#apicontroller-attribute for its effects. + // JsonApiDotNetCore already handles all of these concerns, but in a JSON:API-compliant way. So the attribute doesn't do any good. + + // While we try our best when [ApiController] is used, we can't completely avoid a degraded experience. ModelState validation errors are turned into + // ProblemDetails, where the origin of the error gets lost. As a result, we can't populate the source pointer in JSON:API error responses. + // For backwards-compatibility, we log a warning instead of throwing. But we can't think of any use cases where having [ApiController] makes sense. + + LogApiControllerAttributeFound(controller.ControllerType); + } - if (!isOperationsController) + if (!IsOperationsController(controller.ControllerType)) { Type? resourceClrType = ExtractResourceClrTypeFromController(controller.ControllerType); @@ -74,35 +99,41 @@ public void Apply(ApplicationModel application) { ResourceType? resourceType = _resourceGraph.FindResourceType(resourceClrType); - if (resourceType != null) + if (resourceType == null) { - if (_controllerPerResourceTypeMap.ContainsKey(resourceType)) - { - throw new InvalidConfigurationException($"Multiple controllers found for resource type '{resourceType}'."); - } - - _resourceTypePerControllerTypeMap.Add(controller.ControllerType, resourceType); - _controllerPerResourceTypeMap.Add(resourceType, controller); + throw new InvalidConfigurationException( + $"Controller '{controller.ControllerType}' depends on resource type '{resourceClrType}', which does not exist in the resource graph."); } - else + + if (_controllerPerResourceTypeMap.TryGetValue(resourceType, out ControllerModel? existingModel)) { - throw new InvalidConfigurationException($"Controller '{controller.ControllerType}' depends on " + - $"resource type '{resourceClrType}', which does not exist in the resource graph."); + throw new InvalidConfigurationException( + $"Multiple controllers found for resource type '{resourceType}': '{existingModel.ControllerType}' and '{controller.ControllerType}'."); } + + RemoveDisabledActionMethods(controller, resourceType); + + _resourceTypePerControllerTypeMap.Add(controller.ControllerType, resourceType); + _controllerPerResourceTypeMap.Add(resourceType, controller); } } + else + { + var options = (JsonApiOptions)_options; + options.IncludeExtensions(JsonApiMediaTypeExtension.AtomicOperations, JsonApiMediaTypeExtension.RelaxedAtomicOperations); + } - if (!IsRoutingConventionEnabled(controller)) + if (IsRoutingConventionDisabled(controller)) { continue; } string template = TemplateFromResource(controller) ?? TemplateFromController(controller); - if (_registeredControllerNameByTemplate.ContainsKey(template)) + if (_registeredControllerNameByTemplate.TryGetValue(template, out string? controllerName)) { throw new InvalidConfigurationException( - $"Cannot register '{controller.ControllerType.FullName}' for template '{template}' because '{_registeredControllerNameByTemplate[template]}' was already registered for this template."); + $"Cannot register '{controller.ControllerType.FullName}' for template '{template}' because '{controllerName}' was already registered for this template."); } _registeredControllerNameByTemplate.Add(template, controller.ControllerType.FullName!); @@ -114,35 +145,20 @@ public void Apply(ApplicationModel application) } } - private bool IsRoutingConventionEnabled(ControllerModel controller) + private static bool IsJsonApiController(ControllerModel controller) { - return controller.ControllerType.IsSubclassOf(typeof(CoreJsonApiController)) && - controller.ControllerType.GetCustomAttribute(true) == null; + return controller.ControllerType.IsSubclassOf(typeof(CoreJsonApiController)); } - /// - /// Derives a template from the resource type, and checks if this template was already registered. - /// - private string? TemplateFromResource(ControllerModel model) + private static bool HasApiControllerAttribute(ControllerModel controller) { - if (_resourceTypePerControllerTypeMap.TryGetValue(model.ControllerType, out ResourceType? resourceType)) - { - return $"{_options.Namespace}/{resourceType.PublicName}"; - } - - return null; + return controller.ControllerType.GetCustomAttribute() != null; } - /// - /// Derives a template from the controller name, and checks if this template was already registered. - /// - private string TemplateFromController(ControllerModel model) + private static bool IsOperationsController(Type type) { - string controllerName = _options.SerializerOptions.PropertyNamingPolicy == null - ? model.ControllerName - : _options.SerializerOptions.PropertyNamingPolicy.ConvertName(model.ControllerName); - - return $"{_options.Namespace}/{controllerName}"; + Type baseControllerType = typeof(BaseJsonApiOperationsController); + return baseControllerType.IsAssignableFrom(type); } /// @@ -180,9 +196,50 @@ private string TemplateFromController(ControllerModel model) return currentType?.GetGenericArguments().First(); } - private static bool IsOperationsController(Type type) + private void RemoveDisabledActionMethods(ControllerModel controller, ResourceType resourceType) { - Type baseControllerType = typeof(BaseJsonApiOperationsController); - return baseControllerType.IsAssignableFrom(type); + foreach (ActionModel actionModel in controller.Actions.ToArray()) + { + JsonApiEndpoints endpoint = actionModel.Attributes.OfType().GetJsonApiEndpoint(); + + if (endpoint != JsonApiEndpoints.None && !_jsonApiEndpointFilter.IsEnabled(resourceType, endpoint)) + { + controller.Actions.Remove(actionModel); + } + } } + + private static bool IsRoutingConventionDisabled(ControllerModel controller) + { + return controller.ControllerType.GetCustomAttribute(true) != null; + } + + /// + /// Derives a template from the resource type, and checks if this template was already registered. + /// + private string? TemplateFromResource(ControllerModel model) + { + if (_resourceTypePerControllerTypeMap.TryGetValue(model.ControllerType, out ResourceType? resourceType)) + { + return $"{_options.Namespace}/{resourceType.PublicName}"; + } + + return null; + } + + /// + /// Derives a template from the controller name, and checks if this template was already registered. + /// + private string TemplateFromController(ControllerModel model) + { + string controllerName = _options.SerializerOptions.PropertyNamingPolicy == null + ? model.ControllerName + : _options.SerializerOptions.PropertyNamingPolicy.ConvertName(model.ControllerName); + + return $"{_options.Namespace}/{controllerName}"; + } + + [LoggerMessage(Level = LogLevel.Warning, + Message = "Found JSON:API controller '{ControllerType}' with [ApiController]. Please remove this attribute for optimal JSON:API compliance.")] + private partial void LogApiControllerAttributeFound(TypeInfo controllerType); } diff --git a/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs b/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs index e00bbd50a8..23e6733a44 100644 --- a/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs +++ b/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs @@ -1,9 +1,15 @@ +using System.Linq.Expressions; using System.Reflection; using System.Runtime.CompilerServices; using System.Text; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.QueryableBuilding; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCore.Middleware; @@ -14,50 +20,128 @@ internal abstract class TraceLogWriter { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, - ReferenceHandler = ReferenceHandler.Preserve + ReferenceHandler = ReferenceHandler.IgnoreCycles, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = + { + new JsonStringEnumConverter(), + new ResourceTypeInTraceJsonConverter(), + new ResourceFieldInTraceJsonConverterFactory(), + new AbstractResourceWrapperInTraceJsonConverterFactory(), + new IdentifiableInTraceJsonConverter() + } }; -} -internal sealed class TraceLogWriter : TraceLogWriter -{ - private readonly ILogger _logger; + private sealed class ResourceTypeInTraceJsonConverter : JsonConverter + { + public override ResourceType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotSupportedException(); + } - private bool IsEnabled => _logger.IsEnabled(LogLevel.Trace); + public override void Write(Utf8JsonWriter writer, ResourceType value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.PublicName); + } + } - public TraceLogWriter(ILoggerFactory loggerFactory) + private sealed class ResourceFieldInTraceJsonConverterFactory : JsonConverterFactory { - _logger = loggerFactory.CreateLogger(typeof(T)); + public override bool CanConvert(Type typeToConvert) + { + return typeToConvert.IsAssignableTo(typeof(ResourceFieldAttribute)); + } + + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + Type converterType = typeof(ResourceFieldInTraceJsonConverter<>).MakeGenericType(typeToConvert); + return (JsonConverter)Activator.CreateInstance(converterType)!; + } + + private sealed class ResourceFieldInTraceJsonConverter : JsonConverter + where TField : ResourceFieldAttribute + { + public override TField Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotSupportedException(); + } + + public override void Write(Utf8JsonWriter writer, TField value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.PublicName); + } + } } - public void LogMethodStart(object? parameters = null, [CallerMemberName] string memberName = "") + private sealed class IdentifiableInTraceJsonConverter : JsonConverter { - if (IsEnabled) + public override IIdentifiable Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotSupportedException(); + } + + public override void Write(Utf8JsonWriter writer, IIdentifiable value, JsonSerializerOptions options) { - string message = FormatMessage(memberName, parameters); - WriteMessageToLog(message); + // Intentionally *not* calling GetClrType() because we need delegation to the wrapper converter. + Type runtimeType = value.GetType(); + + JsonSerializer.Serialize(writer, value, runtimeType, options); } } - public void LogMessage(Func messageFactory) + private sealed class AbstractResourceWrapperInTraceJsonConverterFactory : JsonConverterFactory { - if (IsEnabled) + public override bool CanConvert(Type typeToConvert) + { + return typeToConvert.IsAssignableTo(typeof(IAbstractResourceWrapper)); + } + + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) { - string message = messageFactory(); - WriteMessageToLog(message); + Type converterType = typeof(AbstractResourceWrapperInTraceJsonConverter<>).MakeGenericType(typeToConvert); + return (JsonConverter)Activator.CreateInstance(converterType)!; + } + + private sealed class AbstractResourceWrapperInTraceJsonConverter : JsonConverter + where TWrapper : IAbstractResourceWrapper + { + public override TWrapper Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotSupportedException(); + } + + public override void Write(Utf8JsonWriter writer, TWrapper value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteString("ClrType", value.AbstractType.FullName); + writer.WriteString("StringId", value.StringId); + writer.WriteEndObject(); + } } } +} - private static string FormatMessage(string memberName, object? parameters) - { - var builder = new StringBuilder(); +internal sealed partial class TraceLogWriter(ILoggerFactory loggerFactory) : TraceLogWriter +{ + private readonly ILogger _logger = loggerFactory.CreateLogger(); - builder.Append("Entering "); - builder.Append(memberName); - builder.Append('('); - WriteProperties(builder, parameters); - builder.Append(')'); + public void LogMethodStart(object? parameters = null, [CallerMemberName] string memberName = "") + { + if (_logger.IsEnabled(LogLevel.Trace)) + { + var builder = new StringBuilder(); + WriteProperties(builder, parameters); + string parameterValues = builder.ToString(); - return builder.ToString(); + if (parameterValues.Length == 0) + { + LogEnteringMember(memberName); + } + else + { + LogEnteringMemberWithParameters(memberName, parameterValues); + } + } } private static void WriteProperties(StringBuilder builder, object? propertyContainer) @@ -88,26 +172,12 @@ private static void WriteProperty(StringBuilder builder, PropertyInfo property, builder.Append(": "); object? value = property.GetValue(instance); - - if (value == null) - { - builder.Append("null"); - } - else if (value is string stringValue) - { - builder.Append('"'); - builder.Append(stringValue); - builder.Append('"'); - } - else - { - WriteObject(builder, value); - } + WriteObject(builder, value); } - private static void WriteObject(StringBuilder builder, object value) + private static void WriteObject(StringBuilder builder, object? value) { - if (HasToStringOverload(value.GetType())) + if (value != null && value is not string && HasToStringOverload(value.GetType())) { builder.Append(value); } @@ -118,36 +188,64 @@ private static void WriteObject(StringBuilder builder, object value) } } - private static bool HasToStringOverload(Type? type) + private static bool HasToStringOverload(Type type) { - if (type != null) - { - MethodInfo? toStringMethod = type.GetMethod("ToString", Array.Empty()); - - if (toStringMethod != null && toStringMethod.DeclaringType != typeof(object)) - { - return true; - } - } - - return false; + MethodInfo? toStringMethod = type.GetMethod("ToString", []); + return toStringMethod != null && toStringMethod.DeclaringType != typeof(object); } - private static string SerializeObject(object value) + private static string SerializeObject(object? value) { try { return JsonSerializer.Serialize(value, SerializerOptions); } - catch (JsonException) + catch (Exception exception) when (exception is JsonException or NotSupportedException) { // Never crash as a result of logging, this is best-effort only. return "object"; } } - private void WriteMessageToLog(string message) + public void LogDebug(QueryLayer queryLayer) + { + ArgumentNullException.ThrowIfNull(queryLayer); + + LogQueryLayer(queryLayer); + } + + public void LogDebug(Expression expression) { - _logger.LogTrace(message); + ArgumentNullException.ThrowIfNull(expression); + + if (_logger.IsEnabled(LogLevel.Debug)) + { + string? text = ExpressionTreeFormatter.Instance.GetText(expression); + + if (text != null) + { + LogExpression(text); + } + else + { + LogReadableExpressionsAssemblyUnavailable(); + } + } } + + [LoggerMessage(Level = LogLevel.Trace, SkipEnabledCheck = true, Message = "Entering {MemberName}({ParameterValues})")] + private partial void LogEnteringMemberWithParameters(string memberName, string parameterValues); + + [LoggerMessage(Level = LogLevel.Trace, SkipEnabledCheck = true, Message = "Entering {MemberName}()")] + private partial void LogEnteringMember(string memberName); + + [LoggerMessage(Level = LogLevel.Debug, Message = "QueryLayer: {queryLayer}")] + private partial void LogQueryLayer(QueryLayer queryLayer); + + [LoggerMessage(Level = LogLevel.Debug, SkipEnabledCheck = true, Message = "Expression tree: {expression}")] + private partial void LogExpression(string expression); + + [LoggerMessage(Level = LogLevel.Debug, SkipEnabledCheck = true, + Message = "Failed to load assembly. To log expression trees, add a NuGet reference to 'AgileObjects.ReadableExpressions' in your project.")] + private partial void LogReadableExpressionsAssemblyUnavailable(); } diff --git a/src/JsonApiDotNetCore/Properties/AssemblyInfo.cs b/src/JsonApiDotNetCore/Properties/AssemblyInfo.cs index e4f127b725..051885a6bc 100644 --- a/src/JsonApiDotNetCore/Properties/AssemblyInfo.cs +++ b/src/JsonApiDotNetCore/Properties/AssemblyInfo.cs @@ -1,7 +1,7 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Benchmarks")] +[assembly: InternalsVisibleTo("DapperExample")] +[assembly: InternalsVisibleTo("JsonApiDotNetCore.OpenApi.Swashbuckle")] [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 new file mode 100644 index 0000000000..72a311d246 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/EvaluatedIncludeCache.cs @@ -0,0 +1,53 @@ +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries; + +/// +internal sealed class EvaluatedIncludeCache : IEvaluatedIncludeCache +{ + private readonly IQueryConstraintProvider[] _constraintProviders; + private IncludeExpression? _include; + private bool _isAssigned; + + public EvaluatedIncludeCache(IEnumerable constraintProviders) + { + ArgumentNullException.ThrowIfNull(constraintProviders); + + _constraintProviders = constraintProviders as IQueryConstraintProvider[] ?? constraintProviders.ToArray(); + } + + /// + public void Set(IncludeExpression include) + { + ArgumentNullException.ThrowIfNull(include); + + _include = include; + _isAssigned = true; + } + + /// + public IncludeExpression? Get() + { + if (!_isAssigned) + { + // In case someone has replaced the built-in JsonApiResourceService with their own that "forgets" to populate the cache, + // then as a fallback, we feed the requested includes from query string to the response serializer. + + // @formatter:wrap_chained_method_calls chop_always + // @formatter:wrap_before_first_method_call true + + _include = _constraintProviders + .SelectMany(provider => provider.GetConstraints()) + .Where(constraint => constraint.Scope == null) + .Select(constraint => constraint.Expression) + .OfType() + .FirstOrDefault(); + + // @formatter:wrap_before_first_method_call restore + // @formatter:wrap_chained_method_calls restore + _isAssigned = true; + } + + return _include; + } +} diff --git a/src/JsonApiDotNetCore/Queries/ExpressionInScope.cs b/src/JsonApiDotNetCore/Queries/ExpressionInScope.cs index 37d40f127b..4cab3e203b 100644 --- a/src/JsonApiDotNetCore/Queries/ExpressionInScope.cs +++ b/src/JsonApiDotNetCore/Queries/ExpressionInScope.cs @@ -15,7 +15,7 @@ public class ExpressionInScope public ExpressionInScope(ResourceFieldChainExpression? scope, QueryExpression expression) { - ArgumentGuard.NotNull(expression, nameof(expression)); + ArgumentNullException.ThrowIfNull(expression); Scope = scope; Expression = expression; diff --git a/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs index 980a7846bc..306a98d1aa 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs @@ -1,28 +1,35 @@ using System.Collections.Immutable; using System.Text; using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents the "any" filter function, resulting from text such as: any(name,'Jack','Joe') +/// This expression allows to test if an attribute value equals any of the specified constants. It represents the "any" filter function, resulting from +/// text such as: +/// +/// any(owner.name,'Jack','Joe','John') +/// +/// . /// [PublicAPI] public class AnyExpression : FilterExpression { + /// + /// The attribute whose value to compare. Chain format: an optional list of to-one relationships, followed by an attribute. + /// public ResourceFieldChainExpression TargetAttribute { get; } + + /// + /// One or more constants to compare the attribute's value against. + /// public IImmutableSet Constants { get; } public AnyExpression(ResourceFieldChainExpression targetAttribute, IImmutableSet constants) { - ArgumentGuard.NotNull(targetAttribute, nameof(targetAttribute)); - ArgumentGuard.NotNull(constants, nameof(constants)); - - if (constants.Count < 2) - { - throw new ArgumentException("At least two constants are required.", nameof(constants)); - } + ArgumentNullException.ThrowIfNull(targetAttribute); + ArgumentGuard.NotNullNorEmpty(constants); TargetAttribute = targetAttribute; Constants = constants; diff --git a/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs index 9bf1c3bde8..b768f556ce 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs @@ -4,19 +4,44 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents a comparison filter function, resulting from text such as: equals(name,'Joe') +/// This expression allows to compare two operands using a comparison operator. It represents comparison filter functions, resulting from text such as: +/// +/// equals(name,'Joe') +/// +/// , +/// +/// equals(owner,null) +/// +/// , or: +/// +/// greaterOrEqual(count(upVotes),count(downVotes),'1') +/// +/// . /// [PublicAPI] public class ComparisonExpression : FilterExpression { + /// + /// The operator used to compare and . + /// public ComparisonOperator Operator { get; } + + /// + /// The left-hand operand, which can be a function or a field chain. Chain format: an optional list of to-one relationships, followed by an attribute. + /// When comparing equality with null, the chain may also end in a to-one relationship. + /// public QueryExpression Left { get; } + + /// + /// The right-hand operand, which can be a function, a field chain, a constant, or null (if the type of is nullable). Chain format: + /// an optional list of to-one relationships, followed by an attribute. + /// public QueryExpression Right { get; } public ComparisonExpression(ComparisonOperator @operator, QueryExpression left, QueryExpression right) { - ArgumentGuard.NotNull(left, nameof(left)); - ArgumentGuard.NotNull(right, nameof(right)); + ArgumentNullException.ThrowIfNull(left); + ArgumentNullException.ThrowIfNull(right); Operator = @operator; Left = left; diff --git a/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs index 5de89ead7c..f79c49af1b 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs @@ -1,19 +1,32 @@ using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents the "count" function, resulting from text such as: count(articles) +/// This expression allows to determine the number of related resources in a to-many relationship. It represents the "count" function, resulting from +/// text such as: +/// +/// count(articles) +/// +/// . /// [PublicAPI] public class CountExpression : FunctionExpression { + /// + /// The to-many relationship to count related resources for. Chain format: an optional list of to-one relationships, followed by a to-many relationship. + /// public ResourceFieldChainExpression TargetCollection { get; } + /// + /// The CLR type this function returns, which is always . + /// + public override Type ReturnType { get; } = typeof(int); + public CountExpression(ResourceFieldChainExpression targetCollection) { - ArgumentGuard.NotNull(targetCollection, nameof(targetCollection)); + ArgumentNullException.ThrowIfNull(targetCollection); TargetCollection = targetCollection; } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/FilterExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/FilterExpression.cs index 513fbf9ac8..447c8b6138 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/FilterExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/FilterExpression.cs @@ -5,4 +5,8 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// public abstract class FilterExpression : FunctionExpression { + /// + /// The CLR type this function returns, which is always . + /// + public override Type ReturnType { get; } = typeof(bool); } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/FunctionExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/FunctionExpression.cs index 2e0b76b255..886a3906c8 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/FunctionExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/FunctionExpression.cs @@ -5,4 +5,8 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// public abstract class FunctionExpression : QueryExpression { + /// + /// The CLR type this function returns. + /// + public abstract Type ReturnType { get; } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs index c5387106d6..5c40039d4e 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs @@ -1,21 +1,38 @@ using System.Text; using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents the "has" filter function, resulting from text such as: has(articles) or has(articles,equals(isHidden,'false')) +/// This expression allows to test if a to-many relationship has related resources, optionally with a condition. It represents the "has" filter function, +/// resulting from text such as: +/// +/// has(articles) +/// +/// , or: +/// +/// has(articles,equals(isHidden,'false')) +/// +/// . /// [PublicAPI] public class HasExpression : FilterExpression { + /// + /// The to-many relationship to determine related resources for. Chain format: an optional list of to-one relationships, followed by a to-many + /// relationship. + /// public ResourceFieldChainExpression TargetCollection { get; } + + /// + /// An optional filter that is applied on the related resources. Any related resources that do not match the filter are ignored. + /// public FilterExpression? Filter { get; } public HasExpression(ResourceFieldChainExpression targetCollection, FilterExpression? filter) { - ArgumentGuard.NotNull(targetCollection, nameof(targetCollection)); + ArgumentNullException.ThrowIfNull(targetCollection); TargetCollection = targetCollection; Filter = filter; diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IdentifierExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IdentifierExpression.cs index 133af83ad4..5a978b54f6 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IdentifierExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IdentifierExpression.cs @@ -1,8 +1,6 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents the base type for an identifier, such as a field/relationship name, a constant between quotes or null. +/// Represents the base type for an identifier, such as a JSON:API attribute/relationship name, a constant value between quotes, or null. /// -public abstract class IdentifierExpression : QueryExpression -{ -} +public abstract class IdentifierExpression : QueryExpression; diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs b/src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs index b35c48efbd..c729c692b9 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs @@ -29,9 +29,9 @@ internal sealed class IncludeChainConverter /// public IReadOnlyCollection GetRelationshipChains(IncludeExpression include) { - ArgumentGuard.NotNull(include, nameof(include)); + ArgumentNullException.ThrowIfNull(include); - if (!include.Elements.Any()) + if (include.Elements.Count == 0) { return Array.Empty(); } @@ -39,14 +39,14 @@ public IReadOnlyCollection GetRelationshipChains(I var converter = new IncludeToChainsConverter(); converter.Visit(include, null); - return converter.Chains; + return converter.Chains.AsReadOnly(); } private sealed class IncludeToChainsConverter : QueryExpressionVisitor { private readonly Stack _parentRelationshipStack = new(); - public List Chains { get; } = new(); + public List Chains { get; } = []; public override object? VisitInclude(IncludeExpression expression, object? argument) { @@ -60,7 +60,7 @@ private sealed class IncludeToChainsConverter : QueryExpressionVisitor -/// Represents an element in . +/// Represents an element in an tree, resulting from text such as: +/// +/// articles.revisions +/// +/// . /// [PublicAPI] public class IncludeElementExpression : QueryExpression { + /// + /// The JSON:API relationship to include. + /// public RelationshipAttribute Relationship { get; } + + /// + /// The direct children of this subtree. Can be empty. + /// public IImmutableSet Children { get; } public IncludeElementExpression(RelationshipAttribute relationship) @@ -21,8 +32,8 @@ public IncludeElementExpression(RelationshipAttribute relationship) public IncludeElementExpression(RelationshipAttribute relationship, IImmutableSet children) { - ArgumentGuard.NotNull(relationship, nameof(relationship)); - ArgumentGuard.NotNull(children, nameof(children)); + ArgumentNullException.ThrowIfNull(relationship); + ArgumentNullException.ThrowIfNull(children); Relationship = relationship; Children = children; @@ -48,7 +59,7 @@ private string InnerToString(bool toFullString) var builder = new StringBuilder(); builder.Append(toFullString ? $"{Relationship.LeftType.PublicName}:{Relationship.PublicName}" : Relationship.PublicName); - if (Children.Any()) + if (Children.Count > 0) { builder.Append('{'); builder.Append(string.Join(",", Children.Select(child => toFullString ? child.ToFullString() : child.ToString()).OrderBy(name => name))); diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs index a63d87719d..235e811fff 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs @@ -4,7 +4,11 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents an inclusion tree, resulting from text such as: owner,articles.revisions +/// Represents an inclusion tree, resulting from text such as: +/// +/// owner,articles.revisions +/// +/// . /// [PublicAPI] public class IncludeExpression : QueryExpression @@ -13,11 +17,14 @@ public class IncludeExpression : QueryExpression public static readonly IncludeExpression Empty = new(); + /// + /// The direct children of this tree. Use if there are no children. + /// public IImmutableSet Elements { get; } public IncludeExpression(IImmutableSet elements) { - ArgumentGuard.NotNullNorEmpty(elements, nameof(elements)); + ArgumentGuard.NotNullNorEmpty(elements); Elements = elements; } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IsTypeExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IsTypeExpression.cs index a30e31308b..e2f68ee8ec 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IsTypeExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IsTypeExpression.cs @@ -1,24 +1,47 @@ using System.Text; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents the "isType" filter function, resulting from text such as: isType(,men), isType(creator,men) or +/// This expression allows to test if a resource in an inheritance hierarchy can be upcast to a derived type, optionally with a condition where the +/// derived type is accessible. It represents the "isType" filter function, resulting from text such as: +/// +/// isType(,men) +/// +/// , +/// +/// isType(creator,men) +/// +/// , or: +/// /// isType(creator,men,equals(hasBeard,'true')) +/// +/// . /// [PublicAPI] public class IsTypeExpression : FilterExpression { + /// + /// An optional to-one relationship to start from. Chain format: one or more to-one relationships. + /// public ResourceFieldChainExpression? TargetToOneRelationship { get; } + + /// + /// The derived resource type to upcast to. + /// public ResourceType DerivedType { get; } + + /// + /// An optional filter that the derived resource must match. + /// public FilterExpression? Child { get; } public IsTypeExpression(ResourceFieldChainExpression? targetToOneRelationship, ResourceType derivedType, FilterExpression? child) { - ArgumentGuard.NotNull(derivedType, nameof(derivedType)); + ArgumentNullException.ThrowIfNull(derivedType); TargetToOneRelationship = targetToOneRelationship; DerivedType = derivedType; diff --git a/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs index 17c62f230f..50c3b2cd54 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs @@ -1,20 +1,41 @@ +using System.Globalization; using JetBrains.Annotations; namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents a non-null constant value, resulting from text such as: equals(firstName,'Jack') +/// Represents a non-null constant value, resulting from text such as: 'Jack', '123', or: 'true'. /// [PublicAPI] public class LiteralConstantExpression : IdentifierExpression { - public string Value { get; } + // Only used to show the original input in errors and diagnostics. Not part of the semantic expression value. + private readonly string _stringValue; - public LiteralConstantExpression(string text) + /// + /// The constant value. Call to determine the .NET runtime type. + /// + public object TypedValue { get; } + + public LiteralConstantExpression(object typedValue) + : this(typedValue, GetStringValue(typedValue)!) + { + } + + public LiteralConstantExpression(object typedValue, string stringValue) + { + ArgumentNullException.ThrowIfNull(typedValue); + ArgumentNullException.ThrowIfNull(stringValue); + + TypedValue = typedValue; + _stringValue = stringValue; + } + + private static string? GetStringValue(object typedValue) { - ArgumentGuard.NotNull(text, nameof(text)); + ArgumentNullException.ThrowIfNull(typedValue); - Value = text; + return typedValue is IFormattable cultureAwareValue ? cultureAwareValue.ToString(null, CultureInfo.InvariantCulture) : typedValue.ToString(); } public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) @@ -24,8 +45,8 @@ public override TResult Accept(QueryExpressionVisitor -/// Represents a logical filter function, resulting from text such as: and(equals(title,'Work'),has(articles)) +/// This expression allows to test whether one or all of its boolean operands are true. It represents the logical AND/OR filter functions, resulting from +/// text such as: +/// +/// and(equals(title,'Work'),has(articles)) +/// +/// , or: +/// +/// or(equals(title,'Work'),has(articles)) +/// +/// . /// [PublicAPI] public class LogicalExpression : FilterExpression { + /// + /// The operator used to compare . + /// public LogicalOperator Operator { get; } + + /// + /// The list of one or more boolean operands. + /// public IImmutableList Terms { get; } public LogicalExpression(LogicalOperator @operator, params FilterExpression[] terms) @@ -21,7 +37,7 @@ public LogicalExpression(LogicalOperator @operator, params FilterExpression[] te public LogicalExpression(LogicalOperator @operator, IImmutableList terms) { - ArgumentGuard.NotNull(terms, nameof(terms)); + ArgumentNullException.ThrowIfNull(terms); if (terms.Count < 2) { @@ -34,8 +50,10 @@ public LogicalExpression(LogicalOperator @operator, IImmutableList terms = filters.WhereNotNull().ToImmutableArray(); return terms.Length > 1 ? new LogicalExpression(@operator, terms) : terms.FirstOrDefault(); diff --git a/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs index a9c598402b..390940e9d1 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs @@ -5,19 +5,43 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents a text-matching filter function, resulting from text such as: startsWith(name,'A') +/// This expression allows partial matching on the value of a JSON:API attribute. It represents text-matching filter functions, resulting from text such +/// as: +/// +/// startsWith(name,'The') +/// +/// , +/// +/// endsWith(name,'end.') +/// +/// , or: +/// +/// contains(name,'middle') +/// +/// . /// [PublicAPI] public class MatchTextExpression : FilterExpression { + /// + /// The attribute whose value to match. Chain format: an optional list of to-one relationships, followed by an attribute. + /// public ResourceFieldChainExpression TargetAttribute { get; } + + /// + /// The text to match the attribute's value against. + /// public LiteralConstantExpression TextValue { get; } + + /// + /// The kind of matching to perform. + /// public TextMatchKind MatchKind { get; } public MatchTextExpression(ResourceFieldChainExpression targetAttribute, LiteralConstantExpression textValue, TextMatchKind matchKind) { - ArgumentGuard.NotNull(targetAttribute, nameof(targetAttribute)); - ArgumentGuard.NotNull(textValue, nameof(textValue)); + ArgumentNullException.ThrowIfNull(targetAttribute); + ArgumentNullException.ThrowIfNull(textValue); TargetAttribute = targetAttribute; TextValue = textValue; diff --git a/src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs index 4d28c4a9c3..e9567000d7 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs @@ -1,19 +1,26 @@ using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents the "not" filter function, resulting from text such as: not(equals(title,'Work')) +/// This expression allows to test for the logical negation of its operand. It represents the "not" filter function, resulting from text such as: +/// +/// not(equals(title,'Work')) +/// +/// . /// [PublicAPI] public class NotExpression : FilterExpression { + /// + /// The filter whose value to negate. + /// public FilterExpression Child { get; } public NotExpression(FilterExpression child) { - ArgumentGuard.NotNull(child, nameof(child)); + ArgumentNullException.ThrowIfNull(child); Child = child; } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs index bdf1af317d..9685b6625c 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs @@ -1,14 +1,17 @@ using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents the constant null, resulting from text such as: equals(lastName,null) +/// Represents the constant null, resulting from the text: null. /// [PublicAPI] public class NullConstantExpression : IdentifierExpression { + /// + /// Provides access to the singleton instance. + /// public static readonly NullConstantExpression Instance = new(); private NullConstantExpression() diff --git a/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs index 88846f3708..b6e182a714 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs @@ -3,33 +3,43 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents an element in . +/// Represents an element in , resulting from text such as: 1, or: +/// +/// articles:2 +/// +/// . /// [PublicAPI] -public class PaginationElementQueryStringValueExpression : QueryExpression +public class PaginationElementQueryStringValueExpression(ResourceFieldChainExpression? scope, int value, int position) : QueryExpression { - public ResourceFieldChainExpression? Scope { get; } - public int Value { get; } + /// + /// The relationship this pagination applies to. Chain format: zero or more relationships, followed by a to-many relationship. + /// + public ResourceFieldChainExpression? Scope { get; } = scope; - public PaginationElementQueryStringValueExpression(ResourceFieldChainExpression? scope, int value) - { - Scope = scope; - Value = value; - } + /// + /// The numeric pagination value. + /// + public int Value { get; } = value; + + /// + /// The zero-based position in the text of the query string parameter value. + /// + public int Position { get; } = position; public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) { - return visitor.PaginationElementQueryStringValue(this, argument); + return visitor.VisitPaginationElementQueryStringValue(this, argument); } public override string ToString() { - return Scope == null ? Value.ToString() : $"{Scope}: {Value}"; + return Scope == null ? $"{Value} at {Position}" : $"{Scope}: {Value} at {Position}"; } public override string ToFullString() { - return Scope == null ? Value.ToString() : $"{Scope.ToFullString()}: {Value}"; + return Scope == null ? $"{Value} at {Position}" : $"{Scope.ToFullString()}: {Value} at {Position}"; } public override bool Equals(object? obj) @@ -46,11 +56,11 @@ public override bool Equals(object? obj) var other = (PaginationElementQueryStringValueExpression)obj; - return Equals(Scope, other.Scope) && Value == other.Value; + return Equals(Scope, other.Scope) && Value == other.Value && Position == other.Position; } public override int GetHashCode() { - return HashCode.Combine(Scope, Value); + return HashCode.Combine(Scope, Value, Position); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/PaginationExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/PaginationExpression.cs index 97ff8b1456..d9e91ef363 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/PaginationExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/PaginationExpression.cs @@ -9,12 +9,19 @@ namespace JsonApiDotNetCore.Queries.Expressions; [PublicAPI] public class PaginationExpression : QueryExpression { + /// + /// The one-based page number. + /// public PageNumber PageNumber { get; } + + /// + /// The optional page size. + /// public PageSize? PageSize { get; } public PaginationExpression(PageNumber pageNumber, PageSize? pageSize) { - ArgumentGuard.NotNull(pageNumber, nameof(pageNumber)); + ArgumentNullException.ThrowIfNull(pageNumber); PageNumber = pageNumber; PageSize = pageSize; diff --git a/src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs index 594dab297a..2a70ea7d8c 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs @@ -4,23 +4,30 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents pagination in a query string, resulting from text such as: 1,articles:2 +/// Represents pagination in a query string, resulting from text such as: +/// +/// 1,articles:2 +/// +/// . /// [PublicAPI] public class PaginationQueryStringValueExpression : QueryExpression { + /// + /// The list of one or more pagination elements. + /// public IImmutableList Elements { get; } public PaginationQueryStringValueExpression(IImmutableList elements) { - ArgumentGuard.NotNullNorEmpty(elements, nameof(elements)); + ArgumentGuard.NotNullNorEmpty(elements); Elements = elements; } public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) { - return visitor.PaginationQueryStringValue(this, argument); + return visitor.VisitPaginationQueryStringValue(this, argument); } public override string ToString() diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs index 2ff93dafe4..dac7493109 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// /// Represents the base data structure for immutable types that query string parameters are converted into. This intermediate structure is later -/// transformed into system trees that are handled by Entity Framework Core. +/// transformed into System.Linq trees that are handled by Entity Framework Core. /// public abstract class QueryExpression { diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs index 7051e81f73..173c77503c 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs @@ -2,10 +2,12 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; +#pragma warning disable IDE0019 // Use pattern matching + namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Building block for rewriting trees. It walks through nested expressions and updates parent on changes. +/// Building block for rewriting trees. It walks through nested expressions and updates the parent on changes. /// [PublicAPI] public class QueryExpressionRewriter : QueryExpressionVisitor @@ -34,7 +36,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; } @@ -105,25 +107,11 @@ public override QueryExpression VisitIsType(IsTypeExpression expression, TArgume public override QueryExpression? VisitSortElement(SortElementExpression expression, TArgument argument) { - SortElementExpression? newExpression = null; - - if (expression.Count != null) - { - if (Visit(expression.Count, argument) is CountExpression newCount) - { - newExpression = new SortElementExpression(newCount, expression.IsAscending); - } - } - else if (expression.TargetAttribute != null) - { - if (Visit(expression.TargetAttribute, argument) is ResourceFieldChainExpression newTargetAttribute) - { - newExpression = new SortElementExpression(newTargetAttribute, expression.IsAscending); - } - } + QueryExpression? newTarget = Visit(expression.Target, argument); - if (newExpression != null) + if (newTarget != null) { + var newExpression = new SortElementExpression(newTarget, expression.IsAscending); return newExpression.Equals(expression) ? expression : newExpression; } @@ -228,7 +216,7 @@ public override QueryExpression VisitSparseFieldSet(SparseFieldSetExpression exp return null; } - public override QueryExpression PaginationQueryStringValue(PaginationQueryStringValueExpression expression, TArgument argument) + public override QueryExpression VisitPaginationQueryStringValue(PaginationQueryStringValueExpression expression, TArgument argument) { IImmutableList newElements = VisitList(expression.Elements, argument); @@ -236,11 +224,11 @@ public override QueryExpression PaginationQueryStringValue(PaginationQueryString return newExpression.Equals(expression) ? expression : newExpression; } - public override QueryExpression PaginationElementQueryStringValue(PaginationElementQueryStringValueExpression expression, TArgument argument) + public override QueryExpression VisitPaginationElementQueryStringValue(PaginationElementQueryStringValueExpression expression, TArgument argument) { ResourceFieldChainExpression? newScope = expression.Scope != null ? Visit(expression.Scope, argument) as ResourceFieldChainExpression : null; - var newExpression = new PaginationElementQueryStringValueExpression(newScope, expression.Value); + var newExpression = new PaginationElementQueryStringValueExpression(newScope, expression.Value, expression.Position); return newExpression.Equals(expression) ? expression : newExpression; } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs index 7dcf44b1f4..a0472306f7 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs @@ -5,6 +5,12 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// /// Implements the visitor design pattern that enables traversing a tree. /// +/// +/// The type to use for passing custom state between visit methods. +/// +/// +/// The type that is returned from visit methods. +/// [PublicAPI] public abstract class QueryExpressionVisitor { @@ -103,12 +109,12 @@ public virtual TResult VisitQueryStringParameterScope(QueryStringParameterScopeE return DefaultVisit(expression, argument); } - public virtual TResult PaginationQueryStringValue(PaginationQueryStringValueExpression expression, TArgument argument) + public virtual TResult VisitPaginationQueryStringValue(PaginationQueryStringValueExpression expression, TArgument argument) { return DefaultVisit(expression, argument); } - public virtual TResult PaginationElementQueryStringValue(PaginationElementQueryStringValueExpression expression, TArgument argument) + public virtual TResult VisitPaginationElementQueryStringValue(PaginationElementQueryStringValueExpression expression, TArgument argument) { return DefaultVisit(expression, argument); } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs index e567da8778..4063199dd2 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs @@ -3,17 +3,33 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents the scope of a query string parameter, resulting from text such as: ?filter[articles]=... +/// Represents the relationship scope of a query string parameter, resulting from text such as: +/// +/// ?sort[articles] +/// +/// , or: +/// +/// ?filter[author.articles.comments] +/// +/// . /// [PublicAPI] public class QueryStringParameterScopeExpression : QueryExpression { + /// + /// The name of the query string parameter, without its surrounding brackets. + /// public LiteralConstantExpression ParameterName { get; } + + /// + /// The scope this parameter value applies to, or null for the URL endpoint scope. Chain format for the filter/sort parameters: an optional list + /// of relationships, followed by a to-many relationship. + /// public ResourceFieldChainExpression? Scope { get; } public QueryStringParameterScopeExpression(LiteralConstantExpression parameterName, ResourceFieldChainExpression? scope) { - ArgumentGuard.NotNull(parameterName, nameof(parameterName)); + ArgumentNullException.ThrowIfNull(parameterName); ParameterName = parameterName; Scope = scope; diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs index 4cd035ba2d..e3894e7e1f 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs @@ -15,16 +15,14 @@ public class QueryableHandlerExpression : QueryExpression public QueryableHandlerExpression(object queryableHandler, StringValues parameterValue) { - ArgumentGuard.NotNull(queryableHandler, nameof(queryableHandler)); + ArgumentNullException.ThrowIfNull(queryableHandler); _queryableHandler = queryableHandler; _parameterValue = parameterValue; } -#pragma warning disable AV1130 // Return type in method signature should be an interface to an unchangeable collection public IQueryable Apply(IQueryable query) where TResource : class, IIdentifiable -#pragma warning restore AV1130 // Return type in method signature should be an interface to an unchangeable collection { var handler = (Func, StringValues, IQueryable>)_queryableHandler; return handler(query, _parameterValue); diff --git a/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs index 7decec6221..35d5ecc4a1 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs @@ -1,27 +1,39 @@ using System.Collections.Immutable; using JetBrains.Annotations; +using JsonApiDotNetCore.QueryStrings.FieldChains; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents a chain of fields (relationships and attributes), resulting from text such as: articles.revisions.author +/// Represents a chain of JSON:API fields (relationships and attributes), resulting from text such as: +/// +/// articles.revisions.author +/// +/// , or: +/// +/// owner.LastName +/// +/// . /// [PublicAPI] public class ResourceFieldChainExpression : IdentifierExpression { + /// + /// A list of one or more JSON:API fields. Use to convert from text. + /// public IImmutableList Fields { get; } public ResourceFieldChainExpression(ResourceFieldAttribute field) { - ArgumentGuard.NotNull(field, nameof(field)); + ArgumentNullException.ThrowIfNull(field); Fields = ImmutableArray.Create(field); } public ResourceFieldChainExpression(IImmutableList fields) { - ArgumentGuard.NotNullNorEmpty(fields, nameof(fields)); + ArgumentGuard.NotNullNorEmpty(fields); Fields = fields; } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs index 78de440a42..293c1d9c71 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs @@ -4,28 +4,34 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents an element in . +/// Represents an element in , resulting from text such as: lastName, +/// +/// -lastModifiedAt +/// +/// , or: +/// +/// count(children) +/// +/// . /// [PublicAPI] public class SortElementExpression : QueryExpression { - public ResourceFieldChainExpression? TargetAttribute { get; } - public CountExpression? Count { get; } + /// + /// The target to sort on, which can be a function or a field chain. Chain format: an optional list of to-one relationships, followed by an attribute. + /// + public QueryExpression Target { get; } + + /// + /// Indicates the sort direction. + /// public bool IsAscending { get; } - public SortElementExpression(ResourceFieldChainExpression targetAttribute, bool isAscending) + public SortElementExpression(QueryExpression target, bool isAscending) { - ArgumentGuard.NotNull(targetAttribute, nameof(targetAttribute)); + ArgumentNullException.ThrowIfNull(target); - TargetAttribute = targetAttribute; - IsAscending = isAscending; - } - - public SortElementExpression(CountExpression count, bool isAscending) - { - ArgumentGuard.NotNull(count, nameof(count)); - - Count = count; + Target = target; IsAscending = isAscending; } @@ -53,14 +59,7 @@ private string InnerToString(bool toFullString) builder.Append('-'); } - if (TargetAttribute != null) - { - builder.Append(toFullString ? TargetAttribute.ToFullString() : TargetAttribute); - } - else if (Count != null) - { - builder.Append(toFullString ? Count.ToFullString() : Count); - } + builder.Append(toFullString ? Target.ToFullString() : Target); return builder.ToString(); } @@ -79,11 +78,11 @@ public override bool Equals(object? obj) var other = (SortElementExpression)obj; - return Equals(TargetAttribute, other.TargetAttribute) && Equals(Count, other.Count) && IsAscending == other.IsAscending; + return Equals(Target, other.Target) && IsAscending == other.IsAscending; } public override int GetHashCode() { - return HashCode.Combine(TargetAttribute, Count, IsAscending); + return HashCode.Combine(Target, IsAscending); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs index dc0aebd320..9c63e46013 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs @@ -4,16 +4,23 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents a sorting, resulting from text such as: lastName,-lastModifiedAt +/// Represents a sorting, resulting from text such as: +/// +/// lastName,-lastModifiedAt,count(children) +/// +/// . /// [PublicAPI] public class SortExpression : QueryExpression { + /// + /// One or more elements to sort on. + /// public IImmutableList Elements { get; } public SortExpression(IImmutableList elements) { - ArgumentGuard.NotNullNorEmpty(elements, nameof(elements)); + ArgumentGuard.NotNullNorEmpty(elements); Elements = elements; } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs index bc1e611bd8..e075c3f915 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs @@ -5,16 +5,23 @@ namespace JsonApiDotNetCore.Queries.Expressions; /// -/// Represents a sparse fieldset, resulting from text such as: firstName,lastName,articles +/// Represents a sparse fieldset, resulting from text such as: +/// +/// firstName,lastName,articles +/// +/// . /// [PublicAPI] public class SparseFieldSetExpression : QueryExpression { + /// + /// The set of JSON:API fields to include. Chain format: a single field. + /// public IImmutableSet Fields { get; } public SparseFieldSetExpression(IImmutableSet fields) { - ArgumentGuard.NotNullNorEmpty(fields, nameof(fields)); + ArgumentGuard.NotNullNorEmpty(fields); Fields = fields; } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs index 53f9ff0eb6..936071ffd1 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs @@ -14,8 +14,8 @@ public static class SparseFieldSetExpressionExtensions Expression> fieldSelector, IResourceGraph resourceGraph) where TResource : class, IIdentifiable { - ArgumentGuard.NotNull(fieldSelector, nameof(fieldSelector)); - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + ArgumentNullException.ThrowIfNull(fieldSelector); + ArgumentNullException.ThrowIfNull(resourceGraph); SparseFieldSetExpression? newSparseFieldSet = sparseFieldSet; @@ -42,8 +42,8 @@ public static class SparseFieldSetExpressionExtensions Expression> fieldSelector, IResourceGraph resourceGraph) where TResource : class, IIdentifiable { - ArgumentGuard.NotNull(fieldSelector, nameof(fieldSelector)); - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + ArgumentNullException.ThrowIfNull(fieldSelector); + ArgumentNullException.ThrowIfNull(resourceGraph); SparseFieldSetExpression? newSparseFieldSet = sparseFieldSet; diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs index 8e52df9b3b..fc1e9fb88b 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs @@ -11,11 +11,14 @@ namespace JsonApiDotNetCore.Queries.Expressions; [PublicAPI] public class SparseFieldTableExpression : QueryExpression { + /// + /// The set of JSON:API fields to include, per resource type. + /// public IImmutableDictionary Table { get; } public SparseFieldTableExpression(IImmutableDictionary table) { - ArgumentGuard.NotNullNorEmpty(table, nameof(table), "entries"); + ArgumentGuard.NotNullNorEmpty(table); Table = table; } diff --git a/src/JsonApiDotNetCore/Queries/FieldSelection.cs b/src/JsonApiDotNetCore/Queries/FieldSelection.cs index 54c59005bf..b929db2c80 100644 --- a/src/JsonApiDotNetCore/Queries/FieldSelection.cs +++ b/src/JsonApiDotNetCore/Queries/FieldSelection.cs @@ -16,14 +16,14 @@ public sealed class FieldSelection : Dictionary public IReadOnlySet GetResourceTypes() { - return Keys.ToHashSet(); + return Keys.ToHashSet().AsReadOnly(); } #pragma warning disable AV1130 // Return type in method signature should be an interface to an unchangeable collection public FieldSelectors GetOrCreateSelectors(ResourceType resourceType) #pragma warning restore AV1130 // Return type in method signature should be an interface to an unchangeable collection { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + ArgumentNullException.ThrowIfNull(resourceType); if (!ContainsKey(resourceType)) { diff --git a/src/JsonApiDotNetCore/Queries/FieldSelectors.cs b/src/JsonApiDotNetCore/Queries/FieldSelectors.cs index a07b4f0c79..d9ab815638 100644 --- a/src/JsonApiDotNetCore/Queries/FieldSelectors.cs +++ b/src/JsonApiDotNetCore/Queries/FieldSelectors.cs @@ -10,7 +10,7 @@ namespace JsonApiDotNetCore.Queries; [PublicAPI] public sealed class FieldSelectors : Dictionary { - public bool IsEmpty => !this.Any(); + public bool IsEmpty => Count == 0; public bool ContainsReadOnlyAttribute { @@ -24,27 +24,27 @@ public bool ContainsOnlyRelationships { get { - return this.All(selector => selector.Key is RelationshipAttribute); + return Count > 0 && this.All(selector => selector.Key is RelationshipAttribute); } } public bool ContainsField(ResourceFieldAttribute field) { - ArgumentGuard.NotNull(field, nameof(field)); + ArgumentNullException.ThrowIfNull(field); return ContainsKey(field); } public void IncludeAttribute(AttrAttribute attribute) { - ArgumentGuard.NotNull(attribute, nameof(attribute)); + ArgumentNullException.ThrowIfNull(attribute); this[attribute] = null; } public void IncludeAttributes(IEnumerable attributes) { - ArgumentGuard.NotNull(attributes, nameof(attributes)); + ArgumentNullException.ThrowIfNull(attributes); foreach (AttrAttribute attribute in attributes) { @@ -52,9 +52,10 @@ public void IncludeAttributes(IEnumerable attributes) } } - public void IncludeRelationship(RelationshipAttribute relationship, QueryLayer? queryLayer) + public void IncludeRelationship(RelationshipAttribute relationship, QueryLayer queryLayer) { - ArgumentGuard.NotNull(relationship, nameof(relationship)); + ArgumentNullException.ThrowIfNull(relationship); + ArgumentNullException.ThrowIfNull(queryLayer); this[relationship] = queryLayer; } diff --git a/src/JsonApiDotNetCore/Queries/Internal/IEvaluatedIncludeCache.cs b/src/JsonApiDotNetCore/Queries/IEvaluatedIncludeCache.cs similarity index 94% rename from src/JsonApiDotNetCore/Queries/Internal/IEvaluatedIncludeCache.cs rename to src/JsonApiDotNetCore/Queries/IEvaluatedIncludeCache.cs index 93b85c090e..bbc76a8269 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/IEvaluatedIncludeCache.cs +++ b/src/JsonApiDotNetCore/Queries/IEvaluatedIncludeCache.cs @@ -1,7 +1,7 @@ using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Queries.Internal; +namespace JsonApiDotNetCore.Queries; /// /// Provides in-memory storage for the evaluated inclusion tree within a request. This tree is produced from query string and resource definition diff --git a/src/JsonApiDotNetCore/Queries/IPaginationContext.cs b/src/JsonApiDotNetCore/Queries/IPaginationContext.cs index 44578e5277..e39b3ca354 100644 --- a/src/JsonApiDotNetCore/Queries/IPaginationContext.cs +++ b/src/JsonApiDotNetCore/Queries/IPaginationContext.cs @@ -3,18 +3,19 @@ namespace JsonApiDotNetCore.Queries; /// -/// Tracks values used for pagination, which is a combined effort from options, query string parsing and fetching the total number of rows. +/// Tracks values used for top-level pagination, which is a combined effort from options, query string parsing, resource definition callbacks and +/// fetching the total number of rows. /// public interface IPaginationContext { /// - /// The value 1, unless specified from query string. Never null. Cannot be higher than options.MaximumPageNumber. + /// The value 1, unless overridden from query string or resource definition. Should not be higher than . /// PageNumber PageNumber { get; set; } /// - /// The default page size from options, unless specified in query string. Can be null, which means no paging. Cannot be higher than - /// options.MaximumPageSize. + /// The default page size from options, unless overridden from query string or resource definition. Should not be higher than + /// . Can be null, which means pagination is disabled. /// PageSize? PageSize { get; set; } @@ -25,12 +26,12 @@ public interface IPaginationContext bool IsPageFull { get; set; } /// - /// The total number of resources. null when is set to false. + /// The total number of resources, or null when is set to false. /// int? TotalResourceCount { get; set; } /// - /// The total number of resource pages. null when is set to false or + /// The total number of resource pages, or null when is set to false or /// is null. /// int? TotalPageCount { get; } diff --git a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs index a9d99c3b13..8b14590e83 100644 --- a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; @@ -18,7 +19,7 @@ public interface IQueryLayerComposer /// /// Builds a filter from constraints, used to determine total resource count on a secondary collection endpoint. /// - FilterExpression? GetSecondaryFilterFromConstraints(TId primaryId, HasManyAttribute hasManyRelationship); + FilterExpression? GetSecondaryFilterFromConstraints([DisallowNull] TId primaryId, HasManyAttribute hasManyRelationship); /// /// Collects constraints and builds a out of them, used to retrieve the actual resources. @@ -28,7 +29,7 @@ public interface IQueryLayerComposer /// /// Collects constraints and builds a out of them, used to retrieve one resource. /// - QueryLayer ComposeForGetById(TId id, ResourceType primaryResourceType, TopFieldSelection fieldSelection); + QueryLayer ComposeForGetById([DisallowNull] TId id, ResourceType primaryResourceType, TopFieldSelection fieldSelection); /// /// Collects constraints and builds the secondary layer for a relationship endpoint. @@ -38,14 +39,14 @@ public interface IQueryLayerComposer /// /// Wraps a layer for a secondary endpoint into a primary layer, rewriting top-level includes. /// - QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, ResourceType primaryResourceType, TId primaryId, + QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, ResourceType primaryResourceType, [DisallowNull] TId primaryId, RelationshipAttribute relationship); /// /// Builds a query that retrieves the primary resource, including all of its attributes and all targeted relationships, during a create/update/delete /// request. /// - QueryLayer ComposeForUpdate(TId id, ResourceType primaryResourceType); + QueryLayer ComposeForUpdate([DisallowNull] TId id, ResourceType primaryResourceType); /// /// Builds a query for each targeted relationship with a filter to match on its right resource IDs. @@ -60,5 +61,5 @@ QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, Resourc /// /// Builds a query for a to-many relationship with a filter to match on its left and right resource IDs. /// - QueryLayer ComposeForHasMany(HasManyAttribute hasManyRelationship, TId leftId, ICollection rightResourceIds); + QueryLayer ComposeForHasMany(HasManyAttribute hasManyRelationship, [DisallowNull] TId leftId, ICollection rightResourceIds); } diff --git a/src/JsonApiDotNetCore/Queries/Internal/ISparseFieldSetCache.cs b/src/JsonApiDotNetCore/Queries/ISparseFieldSetCache.cs similarity index 97% rename from src/JsonApiDotNetCore/Queries/Internal/ISparseFieldSetCache.cs rename to src/JsonApiDotNetCore/Queries/ISparseFieldSetCache.cs index 32a4724637..22046d3bca 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/ISparseFieldSetCache.cs +++ b/src/JsonApiDotNetCore/Queries/ISparseFieldSetCache.cs @@ -3,7 +3,7 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Internal; +namespace JsonApiDotNetCore.Queries; /// /// Takes sparse fieldsets from s and invokes diff --git a/src/JsonApiDotNetCore/Queries/IndentingStringWriter.cs b/src/JsonApiDotNetCore/Queries/IndentingStringWriter.cs index 2d5a366c28..e5f39b6c77 100644 --- a/src/JsonApiDotNetCore/Queries/IndentingStringWriter.cs +++ b/src/JsonApiDotNetCore/Queries/IndentingStringWriter.cs @@ -2,17 +2,12 @@ namespace JsonApiDotNetCore.Queries; -internal sealed class IndentingStringWriter : IDisposable +internal sealed class IndentingStringWriter(StringBuilder builder) : IDisposable { - private readonly StringBuilder _builder; + private readonly StringBuilder _builder = builder; private int _indentDepth; - public IndentingStringWriter(StringBuilder builder) - { - _builder = builder; - } - public void WriteLine(string? line) { if (_indentDepth > 0) diff --git a/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs b/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs deleted file mode 100644 index 509baf73ee..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs +++ /dev/null @@ -1,23 +0,0 @@ -using JsonApiDotNetCore.Queries.Expressions; - -namespace JsonApiDotNetCore.Queries.Internal; - -/// -internal sealed class EvaluatedIncludeCache : IEvaluatedIncludeCache -{ - private IncludeExpression? _include; - - /// - public void Set(IncludeExpression include) - { - ArgumentGuard.NotNull(include, nameof(include)); - - _include = include; - } - - /// - public IncludeExpression? Get() - { - return _include; - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainInheritanceRequirement.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainInheritanceRequirement.cs deleted file mode 100644 index 4b779d1ccd..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainInheritanceRequirement.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -/// -/// Indicates how to handle derived types when resolving resource field chains. -/// -internal enum FieldChainInheritanceRequirement -{ - /// - /// Do not consider derived types when resolving attributes or relationships. - /// - Disabled, - - /// - /// Consider derived types when resolving attributes or relationships, but fail when multiple matches are found. - /// - RequireSingleMatch -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainRequirements.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainRequirements.cs deleted file mode 100644 index 58ab6f0830..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainRequirements.cs +++ /dev/null @@ -1,32 +0,0 @@ -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -/// -/// Used internally when parsing subexpressions in the query string parsers to indicate requirements when resolving a chain of fields. Note these may be -/// interpreted differently or even discarded completely by the various parser implementations, as they tend to better understand the characteristics of -/// the entire expression being parsed. -/// -[Flags] -public enum FieldChainRequirements -{ - /// - /// Indicates a single , optionally preceded by a chain of s. - /// - EndsInAttribute = 1, - - /// - /// Indicates a single , optionally preceded by a chain of s. - /// - EndsInToOne = 2, - - /// - /// Indicates a single , optionally preceded by a chain of s. - /// - EndsInToMany = 4, - - /// - /// Indicates one or a chain of s. - /// - IsRelationship = EndsInToOne | EndsInToMany -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs deleted file mode 100644 index 705f057bc5..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs +++ /dev/null @@ -1,465 +0,0 @@ -using System.Collections.Immutable; -using System.Reflection; -using Humanizer; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -[PublicAPI] -public class FilterParser : QueryExpressionParser -{ - private readonly IResourceFactory _resourceFactory; - private readonly Action? _validateSingleFieldCallback; - private ResourceType? _resourceTypeInScope; - - public FilterParser(IResourceFactory resourceFactory, Action? validateSingleFieldCallback = null) - { - ArgumentGuard.NotNull(resourceFactory, nameof(resourceFactory)); - - _resourceFactory = resourceFactory; - _validateSingleFieldCallback = validateSingleFieldCallback; - } - - public FilterExpression Parse(string source, ResourceType resourceTypeInScope) - { - ArgumentGuard.NotNull(resourceTypeInScope, nameof(resourceTypeInScope)); - - return InScopeOfResourceType(resourceTypeInScope, () => - { - Tokenize(source); - - FilterExpression expression = ParseFilter(); - - AssertTokenStackIsEmpty(); - - return expression; - }); - } - - protected FilterExpression ParseFilter() - { - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Text) - { - switch (nextToken.Value) - { - case Keywords.Not: - { - return ParseNot(); - } - case Keywords.And: - case Keywords.Or: - { - return ParseLogical(nextToken.Value); - } - case Keywords.Equals: - case Keywords.LessThan: - case Keywords.LessOrEqual: - case Keywords.GreaterThan: - case Keywords.GreaterOrEqual: - { - return ParseComparison(nextToken.Value); - } - case Keywords.Contains: - case Keywords.StartsWith: - case Keywords.EndsWith: - { - return ParseTextMatch(nextToken.Value); - } - case Keywords.Any: - { - return ParseAny(); - } - case Keywords.Has: - { - return ParseHas(); - } - case Keywords.IsType: - { - return ParseIsType(); - } - } - } - - throw new QueryParseException("Filter function expected."); - } - - protected NotExpression ParseNot() - { - EatText(Keywords.Not); - EatSingleCharacterToken(TokenKind.OpenParen); - - FilterExpression child = ParseFilter(); - - EatSingleCharacterToken(TokenKind.CloseParen); - - return new NotExpression(child); - } - - protected LogicalExpression ParseLogical(string operatorName) - { - EatText(operatorName); - EatSingleCharacterToken(TokenKind.OpenParen); - - ImmutableArray.Builder termsBuilder = ImmutableArray.CreateBuilder(); - - FilterExpression term = ParseFilter(); - termsBuilder.Add(term); - - EatSingleCharacterToken(TokenKind.Comma); - - term = ParseFilter(); - termsBuilder.Add(term); - - while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) - { - EatSingleCharacterToken(TokenKind.Comma); - - term = ParseFilter(); - termsBuilder.Add(term); - } - - EatSingleCharacterToken(TokenKind.CloseParen); - - var logicalOperator = Enum.Parse(operatorName.Pascalize()); - return new LogicalExpression(logicalOperator, termsBuilder.ToImmutable()); - } - - protected ComparisonExpression ParseComparison(string operatorName) - { - var comparisonOperator = Enum.Parse(operatorName.Pascalize()); - - EatText(operatorName); - EatSingleCharacterToken(TokenKind.OpenParen); - - // Allow equality comparison of a HasOne relationship with null. - FieldChainRequirements leftChainRequirements = comparisonOperator == ComparisonOperator.Equals - ? FieldChainRequirements.EndsInAttribute | FieldChainRequirements.EndsInToOne - : FieldChainRequirements.EndsInAttribute; - - QueryExpression leftTerm = ParseCountOrField(leftChainRequirements); - - EatSingleCharacterToken(TokenKind.Comma); - - QueryExpression rightTerm = ParseCountOrConstantOrNullOrField(FieldChainRequirements.EndsInAttribute); - - EatSingleCharacterToken(TokenKind.CloseParen); - - if (leftTerm is ResourceFieldChainExpression leftChain) - { - if (leftChainRequirements.HasFlag(FieldChainRequirements.EndsInToOne) && rightTerm is not NullConstantExpression) - { - // Run another pass over left chain to have it fail when chain ends in relationship. - OnResolveFieldChain(leftChain.ToString(), FieldChainRequirements.EndsInAttribute); - } - - PropertyInfo leftProperty = leftChain.Fields[^1].Property; - - if (leftProperty.Name == nameof(Identifiable.Id) && rightTerm is LiteralConstantExpression rightConstant) - { - string id = DeObfuscateStringId(leftProperty.ReflectedType!, rightConstant.Value); - rightTerm = new LiteralConstantExpression(id); - } - } - - return new ComparisonExpression(comparisonOperator, leftTerm, rightTerm); - } - - protected MatchTextExpression ParseTextMatch(string matchFunctionName) - { - EatText(matchFunctionName); - EatSingleCharacterToken(TokenKind.OpenParen); - - ResourceFieldChainExpression targetAttribute = ParseFieldChain(FieldChainRequirements.EndsInAttribute, null); - - EatSingleCharacterToken(TokenKind.Comma); - - LiteralConstantExpression constant = ParseConstant(); - - EatSingleCharacterToken(TokenKind.CloseParen); - - var matchKind = Enum.Parse(matchFunctionName.Pascalize()); - return new MatchTextExpression(targetAttribute, constant, matchKind); - } - - protected AnyExpression ParseAny() - { - EatText(Keywords.Any); - EatSingleCharacterToken(TokenKind.OpenParen); - - ResourceFieldChainExpression targetAttribute = ParseFieldChain(FieldChainRequirements.EndsInAttribute, null); - - EatSingleCharacterToken(TokenKind.Comma); - - ImmutableHashSet.Builder constantsBuilder = ImmutableHashSet.CreateBuilder(); - - LiteralConstantExpression constant = ParseConstant(); - constantsBuilder.Add(constant); - - EatSingleCharacterToken(TokenKind.Comma); - - constant = ParseConstant(); - constantsBuilder.Add(constant); - - while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) - { - EatSingleCharacterToken(TokenKind.Comma); - - constant = ParseConstant(); - constantsBuilder.Add(constant); - } - - EatSingleCharacterToken(TokenKind.CloseParen); - - IImmutableSet constantSet = constantsBuilder.ToImmutable(); - - PropertyInfo targetAttributeProperty = targetAttribute.Fields[^1].Property; - - if (targetAttributeProperty.Name == nameof(Identifiable.Id)) - { - constantSet = DeObfuscateIdConstants(constantSet, targetAttributeProperty); - } - - return new AnyExpression(targetAttribute, constantSet); - } - - private IImmutableSet DeObfuscateIdConstants(IImmutableSet constantSet, - PropertyInfo targetAttributeProperty) - { - ImmutableHashSet.Builder idConstantsBuilder = ImmutableHashSet.CreateBuilder(); - - foreach (LiteralConstantExpression idConstant in constantSet) - { - string stringId = idConstant.Value; - string id = DeObfuscateStringId(targetAttributeProperty.ReflectedType!, stringId); - - idConstantsBuilder.Add(new LiteralConstantExpression(id)); - } - - return idConstantsBuilder.ToImmutable(); - } - - protected HasExpression ParseHas() - { - EatText(Keywords.Has); - EatSingleCharacterToken(TokenKind.OpenParen); - - ResourceFieldChainExpression targetCollection = ParseFieldChain(FieldChainRequirements.EndsInToMany, null); - FilterExpression? filter = null; - - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) - { - EatSingleCharacterToken(TokenKind.Comma); - - filter = ParseFilterInHas((HasManyAttribute)targetCollection.Fields[^1]); - } - - EatSingleCharacterToken(TokenKind.CloseParen); - - return new HasExpression(targetCollection, filter); - } - - private FilterExpression ParseFilterInHas(HasManyAttribute hasManyRelationship) - { - return InScopeOfResourceType(hasManyRelationship.RightType, ParseFilter); - } - - private IsTypeExpression ParseIsType() - { - EatText(Keywords.IsType); - EatSingleCharacterToken(TokenKind.OpenParen); - - ResourceFieldChainExpression? targetToOneRelationship = TryParseToOneRelationshipChain(); - - EatSingleCharacterToken(TokenKind.Comma); - - ResourceType baseType = targetToOneRelationship != null ? ((RelationshipAttribute)targetToOneRelationship.Fields[^1]).RightType : _resourceTypeInScope!; - ResourceType derivedType = ParseDerivedType(baseType); - - FilterExpression? child = TryParseFilterInIsType(derivedType); - - EatSingleCharacterToken(TokenKind.CloseParen); - - return new IsTypeExpression(targetToOneRelationship, derivedType, child); - } - - private ResourceFieldChainExpression? TryParseToOneRelationshipChain() - { - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) - { - return null; - } - - return ParseFieldChain(FieldChainRequirements.EndsInToOne, "Relationship name or , expected."); - } - - private ResourceType ParseDerivedType(ResourceType baseType) - { - if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) - { - string derivedTypeName = token.Value!; - return ResolveDerivedType(baseType, derivedTypeName); - } - - throw new QueryParseException("Resource type expected."); - } - - private ResourceType ResolveDerivedType(ResourceType baseType, string derivedTypeName) - { - ResourceType? derivedType = GetDerivedType(baseType, derivedTypeName); - - if (derivedType == null) - { - throw new QueryParseException($"Resource type '{derivedTypeName}' does not exist or does not derive from '{baseType.PublicName}'."); - } - - return derivedType; - } - - private ResourceType? GetDerivedType(ResourceType baseType, string publicName) - { - foreach (ResourceType derivedType in baseType.DirectlyDerivedTypes) - { - if (derivedType.PublicName == publicName) - { - return derivedType; - } - - ResourceType? nextType = GetDerivedType(derivedType, publicName); - - if (nextType != null) - { - return nextType; - } - } - - return null; - } - - private FilterExpression? TryParseFilterInIsType(ResourceType derivedType) - { - FilterExpression? filter = null; - - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) - { - EatSingleCharacterToken(TokenKind.Comma); - - filter = InScopeOfResourceType(derivedType, ParseFilter); - } - - return filter; - } - - protected QueryExpression ParseCountOrField(FieldChainRequirements chainRequirements) - { - CountExpression? count = TryParseCount(); - - if (count != null) - { - return count; - } - - return ParseFieldChain(chainRequirements, "Count function or field name expected."); - } - - protected QueryExpression ParseCountOrConstantOrNullOrField(FieldChainRequirements chainRequirements) - { - CountExpression? count = TryParseCount(); - - if (count != null) - { - return count; - } - - IdentifierExpression? constantOrNull = TryParseConstantOrNull(); - - if (constantOrNull != null) - { - return constantOrNull; - } - - return ParseFieldChain(chainRequirements, "Count function, value between quotes, null or field name expected."); - } - - protected IdentifierExpression? TryParseConstantOrNull() - { - if (TokenStack.TryPeek(out Token? nextToken)) - { - if (nextToken.Kind == TokenKind.Text && nextToken.Value == Keywords.Null) - { - TokenStack.Pop(); - return NullConstantExpression.Instance; - } - - if (nextToken.Kind == TokenKind.QuotedText) - { - TokenStack.Pop(); - return new LiteralConstantExpression(nextToken.Value!); - } - } - - return null; - } - - protected LiteralConstantExpression ParseConstant() - { - if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.QuotedText) - { - return new LiteralConstantExpression(token.Value!); - } - - throw new QueryParseException("Value between quotes expected."); - } - - private string DeObfuscateStringId(Type resourceClrType, string stringId) - { - IIdentifiable tempResource = _resourceFactory.CreateInstance(resourceClrType); - tempResource.StringId = stringId; - return tempResource.GetTypedId().ToString()!; - } - - protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) - { - if (chainRequirements == FieldChainRequirements.EndsInToMany) - { - return ChainResolver.ResolveToOneChainEndingInToMany(_resourceTypeInScope!, path, FieldChainInheritanceRequirement.Disabled, - _validateSingleFieldCallback); - } - - if (chainRequirements == FieldChainRequirements.EndsInAttribute) - { - return ChainResolver.ResolveToOneChainEndingInAttribute(_resourceTypeInScope!, path, FieldChainInheritanceRequirement.Disabled, - _validateSingleFieldCallback); - } - - if (chainRequirements == FieldChainRequirements.EndsInToOne) - { - return ChainResolver.ResolveToOneChain(_resourceTypeInScope!, path, _validateSingleFieldCallback); - } - - if (chainRequirements.HasFlag(FieldChainRequirements.EndsInAttribute) && chainRequirements.HasFlag(FieldChainRequirements.EndsInToOne)) - { - return ChainResolver.ResolveToOneChainEndingInAttributeOrToOne(_resourceTypeInScope!, path, _validateSingleFieldCallback); - } - - throw new InvalidOperationException($"Unexpected combination of chain requirement flags '{chainRequirements}'."); - } - - private TResult InScopeOfResourceType(ResourceType resourceType, Func action) - { - ResourceType? backupType = _resourceTypeInScope; - - try - { - _resourceTypeInScope = resourceType; - return action(); - } - finally - { - _resourceTypeInScope = backupType; - } - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs deleted file mode 100644 index a453921989..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs +++ /dev/null @@ -1,276 +0,0 @@ -using System.Collections.Immutable; -using System.Text; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -[PublicAPI] -public class IncludeParser : QueryExpressionParser -{ - private static readonly ResourceFieldChainErrorFormatter ErrorFormatter = new(); - - public IncludeExpression Parse(string source, ResourceType resourceTypeInScope, int? maximumDepth) - { - ArgumentGuard.NotNull(resourceTypeInScope, nameof(resourceTypeInScope)); - - Tokenize(source); - - IncludeExpression expression = ParseInclude(resourceTypeInScope, maximumDepth); - - AssertTokenStackIsEmpty(); - ValidateMaximumIncludeDepth(maximumDepth, expression); - - return expression; - } - - protected IncludeExpression ParseInclude(ResourceType resourceTypeInScope, int? maximumDepth) - { - var treeRoot = IncludeTreeNode.CreateRoot(resourceTypeInScope); - - ParseRelationshipChain(treeRoot); - - while (TokenStack.Any()) - { - EatSingleCharacterToken(TokenKind.Comma); - - ParseRelationshipChain(treeRoot); - } - - return treeRoot.ToExpression(); - } - - private void ParseRelationshipChain(IncludeTreeNode treeRoot) - { - // A relationship name usually matches a single relationship, even when overridden in derived types. - // But in the following case, two relationships are matched on GET /shoppingBaskets?include=items: - // - // public abstract class ShoppingBasket : Identifiable - // { - // } - // - // public sealed class SilverShoppingBasket : ShoppingBasket - // { - // [HasMany] - // public ISet
Items { get; get; } - // } - // - // public sealed class PlatinumShoppingBasket : ShoppingBasket - // { - // [HasMany] - // public ISet Items { get; get; } - // } - // - // Now if the include chain has subsequent relationships, we need to scan both Items relationships for matches, - // which is why ParseRelationshipName returns a collection. - // - // The advantage of this unfolding is we don't require callers to upcast in relationship chains. The downside is - // that there's currently no way to include Products without Articles. We could add such optional upcast syntax - // in the future, if desired. - - ICollection children = ParseRelationshipName(treeRoot.AsList()); - - while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Period) - { - EatSingleCharacterToken(TokenKind.Period); - - children = ParseRelationshipName(children); - } - } - - private ICollection ParseRelationshipName(ICollection parents) - { - if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) - { - return LookupRelationshipName(token.Value!, parents); - } - - throw new QueryParseException("Relationship name expected."); - } - - private ICollection LookupRelationshipName(string relationshipName, ICollection parents) - { - List children = new(); - HashSet relationshipsFound = new(); - - foreach (IncludeTreeNode parent in parents) - { - // Depending on the left side of the include chain, we may match relationships anywhere in the resource type hierarchy. - // This is compensated for when rendering the response, which substitutes relationships on base types with the derived ones. - IReadOnlySet relationships = parent.Relationship.RightType.GetRelationshipsInTypeOrDerived(relationshipName); - - if (relationships.Any()) - { - relationshipsFound.AddRange(relationships); - - RelationshipAttribute[] relationshipsToInclude = relationships.Where(relationship => relationship.CanInclude).ToArray(); - ICollection affectedChildren = parent.EnsureChildren(relationshipsToInclude); - children.AddRange(affectedChildren); - } - } - - AssertRelationshipsFound(relationshipsFound, relationshipName, parents); - AssertAtLeastOneCanBeIncluded(relationshipsFound, relationshipName, parents); - - return children; - } - - private static void AssertRelationshipsFound(ISet relationshipsFound, string relationshipName, ICollection parents) - { - if (relationshipsFound.Any()) - { - return; - } - - string[] parentPaths = parents.Select(parent => parent.Path).Distinct().Where(path => path != string.Empty).ToArray(); - string path = parentPaths.Length > 0 ? $"{parentPaths[0]}.{relationshipName}" : relationshipName; - - ResourceType[] parentResourceTypes = parents.Select(parent => parent.Relationship.RightType).Distinct().ToArray(); - - bool hasDerivedTypes = parents.Any(parent => parent.Relationship.RightType.DirectlyDerivedTypes.Count > 0); - - string message = ErrorFormatter.GetForNoneFound(ResourceFieldCategory.Relationship, relationshipName, path, parentResourceTypes, hasDerivedTypes); - throw new QueryParseException(message); - } - - private static void AssertAtLeastOneCanBeIncluded(ISet relationshipsFound, string relationshipName, - ICollection parents) - { - if (relationshipsFound.All(relationship => !relationship.CanInclude)) - { - string parentPath = parents.First().Path; - ResourceType resourceType = relationshipsFound.First().LeftType; - - string message = parentPath == string.Empty - ? $"Including the relationship '{relationshipName}' on '{resourceType}' is not allowed." - : $"Including the relationship '{relationshipName}' in '{parentPath}.{relationshipName}' on '{resourceType}' is not allowed."; - - throw new InvalidQueryStringParameterException("include", "Including the requested relationship is not allowed.", message); - } - } - - private static void ValidateMaximumIncludeDepth(int? maximumDepth, IncludeExpression include) - { - if (maximumDepth != null) - { - Stack parentChain = new(); - - foreach (IncludeElementExpression element in include.Elements) - { - ThrowIfMaximumDepthExceeded(element, parentChain, maximumDepth.Value); - } - } - } - - private static void ThrowIfMaximumDepthExceeded(IncludeElementExpression includeElement, Stack parentChain, int maximumDepth) - { - parentChain.Push(includeElement.Relationship); - - if (parentChain.Count > maximumDepth) - { - string path = string.Join('.', parentChain.Reverse().Select(relationship => relationship.PublicName)); - throw new QueryParseException($"Including '{path}' exceeds the maximum inclusion depth of {maximumDepth}."); - } - - foreach (IncludeElementExpression child in includeElement.Children) - { - ThrowIfMaximumDepthExceeded(child, parentChain, maximumDepth); - } - - parentChain.Pop(); - } - - protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) - { - throw new NotSupportedException(); - } - - private sealed class IncludeTreeNode - { - private readonly IncludeTreeNode? _parent; - private readonly IDictionary _children = new Dictionary(); - - public RelationshipAttribute Relationship { get; } - - public string Path - { - get - { - var pathBuilder = new StringBuilder(); - IncludeTreeNode? parent = this; - - while (parent is { Relationship: not HiddenRootRelationship }) - { - pathBuilder.Insert(0, pathBuilder.Length > 0 ? $"{parent.Relationship.PublicName}." : parent.Relationship.PublicName); - parent = parent._parent; - } - - return pathBuilder.ToString(); - } - } - - private IncludeTreeNode(RelationshipAttribute relationship, IncludeTreeNode? parent) - { - Relationship = relationship; - _parent = parent; - } - - public static IncludeTreeNode CreateRoot(ResourceType resourceType) - { - var relationship = new HiddenRootRelationship(resourceType); - return new IncludeTreeNode(relationship, null); - } - - public ICollection EnsureChildren(ICollection relationships) - { - foreach (RelationshipAttribute relationship in relationships) - { - if (!_children.ContainsKey(relationship)) - { - var newChild = new IncludeTreeNode(relationship, this); - _children.Add(relationship, newChild); - } - } - - return _children.Where(pair => relationships.Contains(pair.Key)).Select(pair => pair.Value).ToList(); - } - - public IncludeExpression ToExpression() - { - IncludeElementExpression element = ToElementExpression(); - - if (element.Relationship is HiddenRootRelationship) - { - return new IncludeExpression(element.Children); - } - - return new IncludeExpression(ImmutableHashSet.Create(element)); - } - - private IncludeElementExpression ToElementExpression() - { - IImmutableSet elementChildren = _children.Values.Select(child => child.ToElementExpression()).ToImmutableHashSet(); - return new IncludeElementExpression(Relationship, elementChildren); - } - - public override string ToString() - { - IncludeExpression include = ToExpression(); - return include.ToFullString(); - } - - private sealed class HiddenRootRelationship : RelationshipAttribute - { - public HiddenRootRelationship(ResourceType rightType) - { - ArgumentGuard.NotNull(rightType, nameof(rightType)); - - RightType = rightType; - PublicName = "<>"; - } - } - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/PaginationParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/PaginationParser.cs deleted file mode 100644 index 29c7713b11..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/PaginationParser.cs +++ /dev/null @@ -1,109 +0,0 @@ -using System.Collections.Immutable; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -[PublicAPI] -public class PaginationParser : QueryExpressionParser -{ - private readonly Action? _validateSingleFieldCallback; - private ResourceType? _resourceTypeInScope; - - public PaginationParser(Action? validateSingleFieldCallback = null) - { - _validateSingleFieldCallback = validateSingleFieldCallback; - } - - public PaginationQueryStringValueExpression Parse(string source, ResourceType resourceTypeInScope) - { - ArgumentGuard.NotNull(resourceTypeInScope, nameof(resourceTypeInScope)); - - _resourceTypeInScope = resourceTypeInScope; - - Tokenize(source); - - PaginationQueryStringValueExpression expression = ParsePagination(); - - AssertTokenStackIsEmpty(); - - return expression; - } - - protected PaginationQueryStringValueExpression ParsePagination() - { - ImmutableArray.Builder elementsBuilder = - ImmutableArray.CreateBuilder(); - - PaginationElementQueryStringValueExpression element = ParsePaginationElement(); - elementsBuilder.Add(element); - - while (TokenStack.Any()) - { - EatSingleCharacterToken(TokenKind.Comma); - - element = ParsePaginationElement(); - elementsBuilder.Add(element); - } - - return new PaginationQueryStringValueExpression(elementsBuilder.ToImmutable()); - } - - protected PaginationElementQueryStringValueExpression ParsePaginationElement() - { - int? number = TryParseNumber(); - - if (number != null) - { - return new PaginationElementQueryStringValueExpression(null, number.Value); - } - - ResourceFieldChainExpression scope = ParseFieldChain(FieldChainRequirements.EndsInToMany, "Number or relationship name expected."); - - EatSingleCharacterToken(TokenKind.Colon); - - number = TryParseNumber(); - - if (number == null) - { - throw new QueryParseException("Number expected."); - } - - return new PaginationElementQueryStringValueExpression(scope, number.Value); - } - - protected int? TryParseNumber() - { - if (TokenStack.TryPeek(out Token? nextToken)) - { - int number; - - if (nextToken.Kind == TokenKind.Minus) - { - TokenStack.Pop(); - - if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text && int.TryParse(token.Value, out number)) - { - return -number; - } - - throw new QueryParseException("Digits expected."); - } - - if (nextToken.Kind == TokenKind.Text && int.TryParse(nextToken.Value, out number)) - { - TokenStack.Pop(); - return number; - } - } - - return null; - } - - protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) - { - return ChainResolver.ResolveToManyChain(_resourceTypeInScope!, path, _validateSingleFieldCallback); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs deleted file mode 100644 index 681c1dd8f4..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs +++ /dev/null @@ -1,115 +0,0 @@ -using System.Collections.Immutable; -using System.Text; -using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -/// -/// The base class for parsing query string parameters, using the Recursive Descent algorithm. -/// -/// -/// Uses a tokenizer to populate a stack of tokens, which is then manipulated from the various parsing routines for subexpressions. Implementations -/// should throw on invalid input. -/// -[PublicAPI] -public abstract class QueryExpressionParser -{ - protected Stack TokenStack { get; private set; } = null!; - private protected ResourceFieldChainResolver ChainResolver { get; } = new(); - - /// - /// Takes a dotted path and walks the resource graph to produce a chain of fields. - /// - protected abstract IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements); - - protected virtual void Tokenize(string source) - { - var tokenizer = new QueryTokenizer(source); - TokenStack = new Stack(tokenizer.EnumerateTokens().Reverse()); - } - - protected ResourceFieldChainExpression ParseFieldChain(FieldChainRequirements chainRequirements, string? alternativeErrorMessage) - { - var pathBuilder = new StringBuilder(); - EatFieldChain(pathBuilder, alternativeErrorMessage); - - IImmutableList chain = OnResolveFieldChain(pathBuilder.ToString(), chainRequirements); - - if (chain.Any()) - { - return new ResourceFieldChainExpression(chain); - } - - throw new QueryParseException(alternativeErrorMessage ?? "Field name expected."); - } - - private void EatFieldChain(StringBuilder pathBuilder, string? alternativeErrorMessage) - { - while (true) - { - if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) - { - pathBuilder.Append(token.Value); - - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Period) - { - EatSingleCharacterToken(TokenKind.Period); - pathBuilder.Append('.'); - } - else - { - return; - } - } - else - { - throw new QueryParseException(alternativeErrorMessage ?? "Field name expected."); - } - } - } - - protected CountExpression? TryParseCount() - { - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Text && nextToken.Value == Keywords.Count) - { - TokenStack.Pop(); - - EatSingleCharacterToken(TokenKind.OpenParen); - - ResourceFieldChainExpression targetCollection = ParseFieldChain(FieldChainRequirements.EndsInToMany, null); - - EatSingleCharacterToken(TokenKind.CloseParen); - - return new CountExpression(targetCollection); - } - - return null; - } - - protected void EatText(string text) - { - if (!TokenStack.TryPop(out Token? token) || token.Kind != TokenKind.Text || token.Value != text) - { - throw new QueryParseException($"{text} expected."); - } - } - - protected void EatSingleCharacterToken(TokenKind kind) - { - if (!TokenStack.TryPop(out Token? token) || token.Kind != kind) - { - char ch = QueryTokenizer.SingleCharacterToTokenKinds.Single(pair => pair.Value == kind).Key; - throw new QueryParseException($"{ch} expected."); - } - } - - protected void AssertTokenStackIsEmpty() - { - if (TokenStack.Any()) - { - throw new QueryParseException("End of expression expected."); - } - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryParseException.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryParseException.cs deleted file mode 100644 index 2265ca56da..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryParseException.cs +++ /dev/null @@ -1,12 +0,0 @@ -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -[PublicAPI] -public sealed class QueryParseException : Exception -{ - public QueryParseException(string message) - : base(message) - { - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryStringParameterScopeParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryStringParameterScopeParser.cs deleted file mode 100644 index 3cba8e4515..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryStringParameterScopeParser.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System.Collections.Immutable; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -[PublicAPI] -public class QueryStringParameterScopeParser : QueryExpressionParser -{ - private readonly FieldChainRequirements _chainRequirements; - private readonly Action? _validateSingleFieldCallback; - private ResourceType? _resourceTypeInScope; - - public QueryStringParameterScopeParser(FieldChainRequirements chainRequirements, - Action? validateSingleFieldCallback = null) - { - _chainRequirements = chainRequirements; - _validateSingleFieldCallback = validateSingleFieldCallback; - } - - public QueryStringParameterScopeExpression Parse(string source, ResourceType resourceTypeInScope) - { - ArgumentGuard.NotNull(resourceTypeInScope, nameof(resourceTypeInScope)); - - _resourceTypeInScope = resourceTypeInScope; - - Tokenize(source); - - QueryStringParameterScopeExpression expression = ParseQueryStringParameterScope(); - - AssertTokenStackIsEmpty(); - - return expression; - } - - protected QueryStringParameterScopeExpression ParseQueryStringParameterScope() - { - if (!TokenStack.TryPop(out Token? token) || token.Kind != TokenKind.Text) - { - throw new QueryParseException("Parameter name expected."); - } - - var name = new LiteralConstantExpression(token.Value!); - - ResourceFieldChainExpression? scope = null; - - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.OpenBracket) - { - TokenStack.Pop(); - - scope = ParseFieldChain(_chainRequirements, null); - - EatSingleCharacterToken(TokenKind.CloseBracket); - } - - return new QueryStringParameterScopeExpression(name, scope); - } - - protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) - { - if (chainRequirements == FieldChainRequirements.EndsInToMany) - { - // The mismatch here (ends-in-to-many being interpreted as entire-chain-must-be-to-many) is intentional. - return ChainResolver.ResolveToManyChain(_resourceTypeInScope!, path, _validateSingleFieldCallback); - } - - if (chainRequirements == FieldChainRequirements.IsRelationship) - { - return ChainResolver.ResolveRelationshipChain(_resourceTypeInScope!, path, _validateSingleFieldCallback); - } - - throw new InvalidOperationException($"Unexpected combination of chain requirement flags '{chainRequirements}'."); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldCategory.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldCategory.cs deleted file mode 100644 index 6630cf2767..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldCategory.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -internal enum ResourceFieldCategory -{ - Field, - Attribute, - Relationship -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainErrorFormatter.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainErrorFormatter.cs deleted file mode 100644 index e15b14893a..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainErrorFormatter.cs +++ /dev/null @@ -1,83 +0,0 @@ -using System.Text; -using JsonApiDotNetCore.Configuration; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -internal sealed class ResourceFieldChainErrorFormatter -{ - public string GetForNotFound(ResourceFieldCategory category, string publicName, string path, ResourceType resourceType, - FieldChainInheritanceRequirement inheritanceRequirement) - { - var builder = new StringBuilder(); - WriteSource(category, publicName, builder); - WritePath(path, publicName, builder); - - builder.Append($" does not exist on resource type '{resourceType.PublicName}'"); - - if (inheritanceRequirement != FieldChainInheritanceRequirement.Disabled && resourceType.DirectlyDerivedTypes.Any()) - { - builder.Append(" or any of its derived types"); - } - - builder.Append('.'); - - return builder.ToString(); - } - - public string GetForMultipleMatches(ResourceFieldCategory category, string publicName, string path) - { - var builder = new StringBuilder(); - WriteSource(category, publicName, builder); - WritePath(path, publicName, builder); - - builder.Append(" is defined on multiple derived types."); - - return builder.ToString(); - } - - public string GetForWrongFieldType(ResourceFieldCategory category, string publicName, string path, ResourceType resourceType, string expected) - { - var builder = new StringBuilder(); - WriteSource(category, publicName, builder); - WritePath(path, publicName, builder); - - builder.Append($" must be {expected} on resource type '{resourceType.PublicName}'."); - - return builder.ToString(); - } - - public string GetForNoneFound(ResourceFieldCategory category, string publicName, string path, ICollection parentResourceTypes, - bool hasDerivedTypes) - { - var builder = new StringBuilder(); - WriteSource(category, publicName, builder); - WritePath(path, publicName, builder); - - if (parentResourceTypes.Count == 1) - { - builder.Append($" does not exist on resource type '{parentResourceTypes.First().PublicName}'"); - } - else - { - string typeNames = string.Join(", ", parentResourceTypes.Select(type => $"'{type.PublicName}'")); - builder.Append($" does not exist on any of the resource types {typeNames}"); - } - - builder.Append(hasDerivedTypes ? " or any of its derived types." : "."); - - return builder.ToString(); - } - - private static void WriteSource(ResourceFieldCategory category, string publicName, StringBuilder builder) - { - builder.Append($"{category} '{publicName}'"); - } - - private static void WritePath(string path, string publicName, StringBuilder builder) - { - if (path != publicName) - { - builder.Append($" in '{path}'"); - } - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs deleted file mode 100644 index 4fb2632557..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs +++ /dev/null @@ -1,301 +0,0 @@ -using System.Collections.Immutable; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -/// -/// Provides helper methods to resolve a chain of fields (relationships and attributes) from the resource graph. -/// -internal sealed class ResourceFieldChainResolver -{ - private static readonly ResourceFieldChainErrorFormatter ErrorFormatter = new(); - - /// - /// Resolves a chain of to-one relationships. - /// author - /// - /// author.address.country - /// - /// - public IImmutableList ResolveToOneChain(ResourceType resourceType, string path, - Action? validateCallback = null) - { - ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); - ResourceType nextResourceType = resourceType; - - foreach (string publicName in path.Split(".")) - { - RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path, FieldChainInheritanceRequirement.Disabled); - - validateCallback?.Invoke(toOneRelationship, nextResourceType, path); - - chainBuilder.Add(toOneRelationship); - nextResourceType = toOneRelationship.RightType; - } - - return chainBuilder.ToImmutable(); - } - - /// - /// Resolves a chain of relationships that ends in a to-many relationship, for example: blogs.owner.articles.comments - /// - public IImmutableList ResolveToManyChain(ResourceType resourceType, string path, - Action? validateCallback = null) - { - ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); - - string[] publicNameParts = path.Split("."); - ResourceType nextResourceType = resourceType; - - foreach (string publicName in publicNameParts[..^1]) - { - RelationshipAttribute relationship = GetRelationship(publicName, nextResourceType, path, FieldChainInheritanceRequirement.Disabled); - - validateCallback?.Invoke(relationship, nextResourceType, path); - - chainBuilder.Add(relationship); - nextResourceType = relationship.RightType; - } - - string lastName = publicNameParts[^1]; - RelationshipAttribute lastToManyRelationship = GetToManyRelationship(lastName, nextResourceType, path, FieldChainInheritanceRequirement.Disabled); - - validateCallback?.Invoke(lastToManyRelationship, nextResourceType, path); - - chainBuilder.Add(lastToManyRelationship); - return chainBuilder.ToImmutable(); - } - - /// - /// Resolves a chain of relationships. - /// - /// blogs.articles.comments - /// - /// - /// author.address - /// - /// - /// articles.revisions.author - /// - /// - public IImmutableList ResolveRelationshipChain(ResourceType resourceType, string path, - Action? validateCallback = null) - { - ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); - ResourceType nextResourceType = resourceType; - - foreach (string publicName in path.Split(".")) - { - RelationshipAttribute relationship = GetRelationship(publicName, nextResourceType, path, FieldChainInheritanceRequirement.Disabled); - - validateCallback?.Invoke(relationship, nextResourceType, path); - - chainBuilder.Add(relationship); - nextResourceType = relationship.RightType; - } - - return chainBuilder.ToImmutable(); - } - - /// - /// Resolves a chain of to-one relationships that ends in an attribute. - /// - /// author.address.country.name - /// - /// name - /// - public IImmutableList ResolveToOneChainEndingInAttribute(ResourceType resourceType, string path, - FieldChainInheritanceRequirement inheritanceRequirement, Action? validateCallback = null) - { - ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); - - string[] publicNameParts = path.Split("."); - ResourceType nextResourceType = resourceType; - - foreach (string publicName in publicNameParts[..^1]) - { - RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path, inheritanceRequirement); - - validateCallback?.Invoke(toOneRelationship, nextResourceType, path); - - chainBuilder.Add(toOneRelationship); - nextResourceType = toOneRelationship.RightType; - } - - string lastName = publicNameParts[^1]; - AttrAttribute lastAttribute = GetAttribute(lastName, nextResourceType, path, inheritanceRequirement); - - validateCallback?.Invoke(lastAttribute, nextResourceType, path); - - chainBuilder.Add(lastAttribute); - return chainBuilder.ToImmutable(); - } - - /// - /// Resolves a chain of to-one relationships that ends in a to-many relationship. - /// - /// article.comments - /// - /// - /// comments - /// - /// - public IImmutableList ResolveToOneChainEndingInToMany(ResourceType resourceType, string path, - FieldChainInheritanceRequirement inheritanceRequirement, Action? validateCallback = null) - { - ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); - - string[] publicNameParts = path.Split("."); - ResourceType nextResourceType = resourceType; - - foreach (string publicName in publicNameParts[..^1]) - { - RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path, inheritanceRequirement); - - validateCallback?.Invoke(toOneRelationship, nextResourceType, path); - - chainBuilder.Add(toOneRelationship); - nextResourceType = toOneRelationship.RightType; - } - - string lastName = publicNameParts[^1]; - - RelationshipAttribute toManyRelationship = GetToManyRelationship(lastName, nextResourceType, path, inheritanceRequirement); - - validateCallback?.Invoke(toManyRelationship, nextResourceType, path); - - chainBuilder.Add(toManyRelationship); - return chainBuilder.ToImmutable(); - } - - /// - /// Resolves a chain of to-one relationships that ends in either an attribute or a to-one relationship. - /// - /// author.address.country.name - /// - /// - /// author.address - /// - /// - public IImmutableList ResolveToOneChainEndingInAttributeOrToOne(ResourceType resourceType, string path, - Action? validateCallback = null) - { - ImmutableArray.Builder chainBuilder = ImmutableArray.CreateBuilder(); - - string[] publicNameParts = path.Split("."); - ResourceType nextResourceType = resourceType; - - foreach (string publicName in publicNameParts[..^1]) - { - RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceType, path, FieldChainInheritanceRequirement.Disabled); - - validateCallback?.Invoke(toOneRelationship, nextResourceType, path); - - chainBuilder.Add(toOneRelationship); - nextResourceType = toOneRelationship.RightType; - } - - string lastName = publicNameParts[^1]; - ResourceFieldAttribute lastField = GetField(lastName, nextResourceType, path); - - if (lastField is HasManyAttribute) - { - string message = ErrorFormatter.GetForWrongFieldType(ResourceFieldCategory.Field, lastName, path, nextResourceType, - "an attribute or a to-one relationship"); - - throw new QueryParseException(message); - } - - validateCallback?.Invoke(lastField, nextResourceType, path); - - chainBuilder.Add(lastField); - return chainBuilder.ToImmutable(); - } - - private RelationshipAttribute GetRelationship(string publicName, ResourceType resourceType, string path, - FieldChainInheritanceRequirement inheritanceRequirement) - { - IReadOnlyCollection relationships = inheritanceRequirement == FieldChainInheritanceRequirement.Disabled - ? resourceType.FindRelationshipByPublicName(publicName)?.AsArray() ?? Array.Empty() - : resourceType.GetRelationshipsInTypeOrDerived(publicName); - - if (relationships.Count == 0) - { - string message = ErrorFormatter.GetForNotFound(ResourceFieldCategory.Relationship, publicName, path, resourceType, inheritanceRequirement); - throw new QueryParseException(message); - } - - if (inheritanceRequirement == FieldChainInheritanceRequirement.RequireSingleMatch && relationships.Count > 1) - { - string message = ErrorFormatter.GetForMultipleMatches(ResourceFieldCategory.Relationship, publicName, path); - throw new QueryParseException(message); - } - - return relationships.First(); - } - - private RelationshipAttribute GetToManyRelationship(string publicName, ResourceType resourceType, string path, - FieldChainInheritanceRequirement inheritanceRequirement) - { - RelationshipAttribute relationship = GetRelationship(publicName, resourceType, path, inheritanceRequirement); - - if (relationship is not HasManyAttribute) - { - string message = ErrorFormatter.GetForWrongFieldType(ResourceFieldCategory.Relationship, publicName, path, resourceType, "a to-many relationship"); - throw new QueryParseException(message); - } - - return relationship; - } - - private RelationshipAttribute GetToOneRelationship(string publicName, ResourceType resourceType, string path, - FieldChainInheritanceRequirement inheritanceRequirement) - { - RelationshipAttribute relationship = GetRelationship(publicName, resourceType, path, inheritanceRequirement); - - if (relationship is not HasOneAttribute) - { - string message = ErrorFormatter.GetForWrongFieldType(ResourceFieldCategory.Relationship, publicName, path, resourceType, "a to-one relationship"); - throw new QueryParseException(message); - } - - return relationship; - } - - private AttrAttribute GetAttribute(string publicName, ResourceType resourceType, string path, FieldChainInheritanceRequirement inheritanceRequirement) - { - IReadOnlyCollection attributes = inheritanceRequirement == FieldChainInheritanceRequirement.Disabled - ? resourceType.FindAttributeByPublicName(publicName)?.AsArray() ?? Array.Empty() - : resourceType.GetAttributesInTypeOrDerived(publicName); - - if (attributes.Count == 0) - { - string message = ErrorFormatter.GetForNotFound(ResourceFieldCategory.Attribute, publicName, path, resourceType, inheritanceRequirement); - throw new QueryParseException(message); - } - - if (inheritanceRequirement == FieldChainInheritanceRequirement.RequireSingleMatch && attributes.Count > 1) - { - string message = ErrorFormatter.GetForMultipleMatches(ResourceFieldCategory.Attribute, publicName, path); - throw new QueryParseException(message); - } - - return attributes.First(); - } - - public ResourceFieldAttribute GetField(string publicName, ResourceType resourceType, string path) - { - ResourceFieldAttribute? field = resourceType.Fields.FirstOrDefault(nextField => nextField.PublicName == publicName); - - if (field == null) - { - string message = ErrorFormatter.GetForNotFound(ResourceFieldCategory.Field, publicName, path, resourceType, - FieldChainInheritanceRequirement.Disabled); - - throw new QueryParseException(message); - } - - return field; - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs deleted file mode 100644 index 84782c2b3e..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs +++ /dev/null @@ -1,115 +0,0 @@ -using System.Collections.Immutable; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -[PublicAPI] -public class SortParser : QueryExpressionParser -{ - private readonly Action? _validateSingleFieldCallback; - private ResourceType? _resourceTypeInScope; - - public SortParser(Action? validateSingleFieldCallback = null) - { - _validateSingleFieldCallback = validateSingleFieldCallback; - } - - public SortExpression Parse(string source, ResourceType resourceTypeInScope) - { - ArgumentGuard.NotNull(resourceTypeInScope, nameof(resourceTypeInScope)); - - _resourceTypeInScope = resourceTypeInScope; - - Tokenize(source); - - SortExpression expression = ParseSort(); - - AssertTokenStackIsEmpty(); - - return expression; - } - - protected SortExpression ParseSort() - { - SortElementExpression firstElement = ParseSortElement(); - - ImmutableArray.Builder elementsBuilder = ImmutableArray.CreateBuilder(); - elementsBuilder.Add(firstElement); - - while (TokenStack.Any()) - { - EatSingleCharacterToken(TokenKind.Comma); - - SortElementExpression nextElement = ParseSortElement(); - elementsBuilder.Add(nextElement); - } - - return new SortExpression(elementsBuilder.ToImmutable()); - } - - protected SortElementExpression ParseSortElement() - { - bool isAscending = true; - - if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Minus) - { - TokenStack.Pop(); - isAscending = false; - } - - CountExpression? count = TryParseCount(); - - if (count != null) - { - return new SortElementExpression(count, isAscending); - } - - string errorMessage = isAscending ? "-, count function or field name expected." : "Count function or field name expected."; - ResourceFieldChainExpression targetAttribute = ParseFieldChain(FieldChainRequirements.EndsInAttribute, errorMessage); - return new SortElementExpression(targetAttribute, isAscending); - } - - protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) - { - // An attribute or relationship name usually matches a single field, even when overridden in derived types. - // But in the following case, two attributes are matched on GET /shoppingBaskets?sort=bonusPoints: - // - // public abstract class ShoppingBasket : Identifiable - // { - // } - // - // public sealed class SilverShoppingBasket : ShoppingBasket - // { - // [Attr] - // public short BonusPoints { get; set; } - // } - // - // public sealed class PlatinumShoppingBasket : ShoppingBasket - // { - // [Attr] - // public long BonusPoints { get; set; } - // } - // - // In this case there are two distinct BonusPoints fields (with different data types). And the sort order depends - // on which attribute is used. - // - // Because there is no syntax to pick one, we fail with an error. We could add such optional upcast syntax - // (which would be required in this case) in the future to make it work, if desired. - - if (chainRequirements == FieldChainRequirements.EndsInToMany) - { - return ChainResolver.ResolveToOneChainEndingInToMany(_resourceTypeInScope!, path, FieldChainInheritanceRequirement.RequireSingleMatch); - } - - if (chainRequirements == FieldChainRequirements.EndsInAttribute) - { - return ChainResolver.ResolveToOneChainEndingInAttribute(_resourceTypeInScope!, path, FieldChainInheritanceRequirement.RequireSingleMatch, - _validateSingleFieldCallback); - } - - throw new InvalidOperationException($"Unexpected combination of chain requirement flags '{chainRequirements}'."); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs deleted file mode 100644 index b4e54f0c46..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System.Collections.Immutable; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -[PublicAPI] -public class SparseFieldSetParser : QueryExpressionParser -{ - private readonly Action? _validateSingleFieldCallback; - private ResourceType? _resourceType; - - public SparseFieldSetParser(Action? validateSingleFieldCallback = null) - { - _validateSingleFieldCallback = validateSingleFieldCallback; - } - - public SparseFieldSetExpression? Parse(string source, ResourceType resourceType) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - - _resourceType = resourceType; - - Tokenize(source); - - SparseFieldSetExpression? expression = ParseSparseFieldSet(); - - AssertTokenStackIsEmpty(); - - return expression; - } - - protected SparseFieldSetExpression? ParseSparseFieldSet() - { - ImmutableHashSet.Builder fieldSetBuilder = ImmutableHashSet.CreateBuilder(); - - while (TokenStack.Any()) - { - if (fieldSetBuilder.Count > 0) - { - EatSingleCharacterToken(TokenKind.Comma); - } - - ResourceFieldChainExpression nextChain = ParseFieldChain(FieldChainRequirements.EndsInAttribute, "Field name expected."); - ResourceFieldAttribute nextField = nextChain.Fields.Single(); - fieldSetBuilder.Add(nextField); - } - - return fieldSetBuilder.Any() ? new SparseFieldSetExpression(fieldSetBuilder.ToImmutable()) : null; - } - - protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) - { - ResourceFieldAttribute field = ChainResolver.GetField(path, _resourceType!, path); - - _validateSingleFieldCallback?.Invoke(field, _resourceType!, path); - - return ImmutableArray.Create(field); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs deleted file mode 100644 index b23dfdfea1..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System.Collections.Immutable; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -[PublicAPI] -public class SparseFieldTypeParser : QueryExpressionParser -{ - private readonly IResourceGraph _resourceGraph; - - public SparseFieldTypeParser(IResourceGraph resourceGraph) - { - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - - _resourceGraph = resourceGraph; - } - - public ResourceType Parse(string source) - { - Tokenize(source); - - ResourceType resourceType = ParseSparseFieldTarget(); - - AssertTokenStackIsEmpty(); - - return resourceType; - } - - private ResourceType ParseSparseFieldTarget() - { - if (!TokenStack.TryPop(out Token? token) || token.Kind != TokenKind.Text) - { - throw new QueryParseException("Parameter name expected."); - } - - EatSingleCharacterToken(TokenKind.OpenBracket); - - ResourceType resourceType = ParseResourceType(); - - EatSingleCharacterToken(TokenKind.CloseBracket); - - return resourceType; - } - - private ResourceType ParseResourceType() - { - if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) - { - return GetResourceType(token.Value!); - } - - throw new QueryParseException("Resource type expected."); - } - - private ResourceType GetResourceType(string publicName) - { - ResourceType? resourceType = _resourceGraph.FindResourceType(publicName); - - if (resourceType == null) - { - throw new QueryParseException($"Resource type '{publicName}' does not exist."); - } - - return resourceType; - } - - protected override IImmutableList OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) - { - throw new NotSupportedException(); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/Token.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/Token.cs deleted file mode 100644 index bff295acae..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/Token.cs +++ /dev/null @@ -1,26 +0,0 @@ -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing; - -[PublicAPI] -public sealed class Token -{ - public TokenKind Kind { get; } - public string? Value { get; } - - public Token(TokenKind kind) - { - Kind = kind; - } - - public Token(TokenKind kind, string value) - : this(kind) - { - Value = value; - } - - public override string ToString() - { - return Value == null ? Kind.ToString() : $"{Kind}: {Value}"; - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs deleted file mode 100644 index 40af882044..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs +++ /dev/null @@ -1,564 +0,0 @@ -using System.Collections.Immutable; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Diagnostics; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal; - -/// -[PublicAPI] -public class QueryLayerComposer : IQueryLayerComposer -{ - private readonly CollectionConverter _collectionConverter = new(); - private readonly IEnumerable _constraintProviders; - private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; - private readonly IJsonApiOptions _options; - private readonly IPaginationContext _paginationContext; - private readonly ITargetedFields _targetedFields; - private readonly IEvaluatedIncludeCache _evaluatedIncludeCache; - private readonly ISparseFieldSetCache _sparseFieldSetCache; - - public QueryLayerComposer(IEnumerable constraintProviders, IResourceDefinitionAccessor resourceDefinitionAccessor, - IJsonApiOptions options, IPaginationContext paginationContext, ITargetedFields targetedFields, IEvaluatedIncludeCache evaluatedIncludeCache, - ISparseFieldSetCache sparseFieldSetCache) - { - ArgumentGuard.NotNull(constraintProviders, nameof(constraintProviders)); - ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(paginationContext, nameof(paginationContext)); - ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); - ArgumentGuard.NotNull(evaluatedIncludeCache, nameof(evaluatedIncludeCache)); - ArgumentGuard.NotNull(sparseFieldSetCache, nameof(sparseFieldSetCache)); - - _constraintProviders = constraintProviders; - _resourceDefinitionAccessor = resourceDefinitionAccessor; - _options = options; - _paginationContext = paginationContext; - _targetedFields = targetedFields; - _evaluatedIncludeCache = evaluatedIncludeCache; - _sparseFieldSetCache = sparseFieldSetCache; - } - - /// - public FilterExpression? GetPrimaryFilterFromConstraints(ResourceType primaryResourceType) - { - ExpressionInScope[] constraints = _constraintProviders.SelectMany(provider => provider.GetConstraints()).ToArray(); - - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - FilterExpression[] filtersInTopScope = constraints - .Where(constraint => constraint.Scope == null) - .Select(constraint => constraint.Expression) - .OfType() - .ToArray(); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - return GetFilter(filtersInTopScope, primaryResourceType); - } - - /// - public FilterExpression? GetSecondaryFilterFromConstraints(TId primaryId, HasManyAttribute hasManyRelationship) - { - ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship)); - - if (hasManyRelationship.InverseNavigationProperty == null) - { - return null; - } - - RelationshipAttribute? inverseRelationship = - hasManyRelationship.RightType.FindRelationshipByPropertyName(hasManyRelationship.InverseNavigationProperty.Name); - - if (inverseRelationship == null) - { - return null; - } - - ExpressionInScope[] constraints = _constraintProviders.SelectMany(provider => provider.GetConstraints()).ToArray(); - - var secondaryScope = new ResourceFieldChainExpression(hasManyRelationship); - - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - FilterExpression[] filtersInSecondaryScope = constraints - .Where(constraint => secondaryScope.Equals(constraint.Scope)) - .Select(constraint => constraint.Expression) - .OfType() - .ToArray(); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - FilterExpression? primaryFilter = GetFilter(Array.Empty(), hasManyRelationship.LeftType); - FilterExpression? secondaryFilter = GetFilter(filtersInSecondaryScope, hasManyRelationship.RightType); - - FilterExpression inverseFilter = GetInverseRelationshipFilter(primaryId, hasManyRelationship, inverseRelationship); - - return LogicalExpression.Compose(LogicalOperator.And, inverseFilter, primaryFilter, secondaryFilter); - } - - private static FilterExpression GetInverseRelationshipFilter(TId primaryId, HasManyAttribute relationship, RelationshipAttribute inverseRelationship) - { - return inverseRelationship is HasManyAttribute hasManyInverseRelationship - ? GetInverseHasManyRelationshipFilter(primaryId, relationship, hasManyInverseRelationship) - : GetInverseHasOneRelationshipFilter(primaryId, relationship, (HasOneAttribute)inverseRelationship); - } - - private static FilterExpression GetInverseHasOneRelationshipFilter(TId primaryId, HasManyAttribute relationship, HasOneAttribute inverseRelationship) - { - AttrAttribute idAttribute = GetIdAttribute(relationship.LeftType); - var idChain = new ResourceFieldChainExpression(ImmutableArray.Create(inverseRelationship, idAttribute)); - - return new ComparisonExpression(ComparisonOperator.Equals, idChain, new LiteralConstantExpression(primaryId!.ToString()!)); - } - - private static FilterExpression GetInverseHasManyRelationshipFilter(TId primaryId, HasManyAttribute relationship, HasManyAttribute inverseRelationship) - { - AttrAttribute idAttribute = GetIdAttribute(relationship.LeftType); - var idChain = new ResourceFieldChainExpression(ImmutableArray.Create(idAttribute)); - var idComparison = new ComparisonExpression(ComparisonOperator.Equals, idChain, new LiteralConstantExpression(primaryId!.ToString()!)); - - return new HasExpression(new ResourceFieldChainExpression(inverseRelationship), idComparison); - } - - /// - public QueryLayer ComposeFromConstraints(ResourceType requestResourceType) - { - ArgumentGuard.NotNull(requestResourceType, nameof(requestResourceType)); - - ExpressionInScope[] constraints = _constraintProviders.SelectMany(provider => provider.GetConstraints()).ToArray(); - - QueryLayer topLayer = ComposeTopLayer(constraints, requestResourceType); - topLayer.Include = ComposeChildren(topLayer, constraints); - - _evaluatedIncludeCache.Set(topLayer.Include); - - return topLayer; - } - - private QueryLayer ComposeTopLayer(IEnumerable constraints, ResourceType resourceType) - { - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Top-level query composition"); - - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - QueryExpression[] expressionsInTopScope = constraints - .Where(constraint => constraint.Scope == null) - .Select(constraint => constraint.Expression) - .ToArray(); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - PaginationExpression topPagination = GetPagination(expressionsInTopScope, resourceType); - _paginationContext.PageSize = topPagination.PageSize; - _paginationContext.PageNumber = topPagination.PageNumber; - - return new QueryLayer(resourceType) - { - Filter = GetFilter(expressionsInTopScope, resourceType), - Sort = GetSort(expressionsInTopScope, resourceType), - Pagination = topPagination, - Selection = GetSelectionForSparseAttributeSet(resourceType) - }; - } - - private IncludeExpression ComposeChildren(QueryLayer topLayer, ICollection constraints) - { - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Nested query composition"); - - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - IncludeExpression include = constraints - .Where(constraint => constraint.Scope == null) - .Select(constraint => constraint.Expression) - .OfType() - .FirstOrDefault() ?? IncludeExpression.Empty; - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - IImmutableSet includeElements = ProcessIncludeSet(include.Elements, topLayer, new List(), constraints); - - return !ReferenceEquals(includeElements, include.Elements) - ? includeElements.Any() ? new IncludeExpression(includeElements) : IncludeExpression.Empty - : include; - } - - private IImmutableSet ProcessIncludeSet(IImmutableSet includeElements, QueryLayer parentLayer, - ICollection parentRelationshipChain, ICollection constraints) - { - IImmutableSet includeElementsEvaluated = GetIncludeElements(includeElements, parentLayer.ResourceType); - - var updatesInChildren = new Dictionary>(); - - foreach (IncludeElementExpression includeElement in includeElementsEvaluated) - { - parentLayer.Selection ??= new FieldSelection(); - FieldSelectors selectors = parentLayer.Selection.GetOrCreateSelectors(parentLayer.ResourceType); - - if (!selectors.ContainsField(includeElement.Relationship)) - { - var relationshipChain = new List(parentRelationshipChain) - { - includeElement.Relationship - }; - - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - QueryExpression[] expressionsInCurrentScope = constraints - .Where(constraint => - constraint.Scope != null && constraint.Scope.Fields.SequenceEqual(relationshipChain)) - .Select(constraint => constraint.Expression) - .ToArray(); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - ResourceType resourceType = includeElement.Relationship.RightType; - bool isToManyRelationship = includeElement.Relationship is HasManyAttribute; - - var child = new QueryLayer(resourceType) - { - Filter = isToManyRelationship ? GetFilter(expressionsInCurrentScope, resourceType) : null, - Sort = isToManyRelationship ? GetSort(expressionsInCurrentScope, resourceType) : null, - Pagination = isToManyRelationship ? GetPagination(expressionsInCurrentScope, resourceType) : null, - Selection = GetSelectionForSparseAttributeSet(resourceType) - }; - - selectors.IncludeRelationship(includeElement.Relationship, child); - - IImmutableSet updatedChildren = ProcessIncludeSet(includeElement.Children, child, relationshipChain, constraints); - - if (!ReferenceEquals(includeElement.Children, updatedChildren)) - { - updatesInChildren.Add(includeElement, updatedChildren); - } - } - } - - return !updatesInChildren.Any() ? includeElementsEvaluated : ApplyIncludeElementUpdates(includeElementsEvaluated, updatesInChildren); - } - - private static IImmutableSet ApplyIncludeElementUpdates(IImmutableSet includeElements, - IDictionary> updatesInChildren) - { - ImmutableHashSet.Builder newElementsBuilder = ImmutableHashSet.CreateBuilder(); - newElementsBuilder.AddRange(includeElements); - - foreach ((IncludeElementExpression existingElement, IImmutableSet updatedChildren) in updatesInChildren) - { - newElementsBuilder.Remove(existingElement); - newElementsBuilder.Add(new IncludeElementExpression(existingElement.Relationship, updatedChildren)); - } - - return newElementsBuilder.ToImmutable(); - } - - /// - public QueryLayer ComposeForGetById(TId id, ResourceType primaryResourceType, TopFieldSelection fieldSelection) - { - ArgumentGuard.NotNull(primaryResourceType, nameof(primaryResourceType)); - - AttrAttribute idAttribute = GetIdAttribute(primaryResourceType); - - QueryLayer queryLayer = ComposeFromConstraints(primaryResourceType); - queryLayer.Sort = null; - queryLayer.Pagination = null; - queryLayer.Filter = CreateFilterByIds(id.AsArray(), idAttribute, queryLayer.Filter); - - if (fieldSelection == TopFieldSelection.OnlyIdAttribute) - { - queryLayer.Selection = new FieldSelection(); - FieldSelectors selectors = queryLayer.Selection.GetOrCreateSelectors(primaryResourceType); - selectors.IncludeAttribute(idAttribute); - } - else if (fieldSelection == TopFieldSelection.WithAllAttributes && queryLayer.Selection != null) - { - // Discard any top-level ?fields[]= or attribute exclusions from resource definition, because we need the full database row. - FieldSelectors selectors = queryLayer.Selection.GetOrCreateSelectors(primaryResourceType); - selectors.RemoveAttributes(); - } - - return queryLayer; - } - - /// - public QueryLayer ComposeSecondaryLayerForRelationship(ResourceType secondaryResourceType) - { - ArgumentGuard.NotNull(secondaryResourceType, nameof(secondaryResourceType)); - - QueryLayer secondaryLayer = ComposeFromConstraints(secondaryResourceType); - secondaryLayer.Selection = GetSelectionForRelationship(secondaryResourceType); - secondaryLayer.Include = null; - - return secondaryLayer; - } - - private FieldSelection GetSelectionForRelationship(ResourceType secondaryResourceType) - { - var selection = new FieldSelection(); - FieldSelectors selectors = selection.GetOrCreateSelectors(secondaryResourceType); - - IImmutableSet secondaryAttributeSet = _sparseFieldSetCache.GetIdAttributeSetForRelationshipQuery(secondaryResourceType); - selectors.IncludeAttributes(secondaryAttributeSet); - - return selection; - } - - /// - public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, ResourceType primaryResourceType, TId primaryId, - RelationshipAttribute relationship) - { - ArgumentGuard.NotNull(secondaryLayer, nameof(secondaryLayer)); - ArgumentGuard.NotNull(primaryResourceType, nameof(primaryResourceType)); - ArgumentGuard.NotNull(relationship, nameof(relationship)); - - IncludeExpression? innerInclude = secondaryLayer.Include; - secondaryLayer.Include = null; - - var primarySelection = new FieldSelection(); - FieldSelectors primarySelectors = primarySelection.GetOrCreateSelectors(primaryResourceType); - - IImmutableSet primaryAttributeSet = _sparseFieldSetCache.GetIdAttributeSetForRelationshipQuery(primaryResourceType); - primarySelectors.IncludeAttributes(primaryAttributeSet); - primarySelectors.IncludeRelationship(relationship, secondaryLayer); - - FilterExpression? primaryFilter = GetFilter(Array.Empty(), primaryResourceType); - AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResourceType); - - return new QueryLayer(primaryResourceType) - { - Include = RewriteIncludeForSecondaryEndpoint(innerInclude, relationship), - Filter = CreateFilterByIds(primaryId.AsArray(), primaryIdAttribute, primaryFilter), - Selection = primarySelection - }; - } - - private IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression? relativeInclude, RelationshipAttribute secondaryRelationship) - { - IncludeElementExpression parentElement = relativeInclude != null - ? new IncludeElementExpression(secondaryRelationship, relativeInclude.Elements) - : new IncludeElementExpression(secondaryRelationship); - - return new IncludeExpression(ImmutableHashSet.Create(parentElement)); - } - - private FilterExpression? CreateFilterByIds(IReadOnlyCollection ids, AttrAttribute idAttribute, FilterExpression? existingFilter) - { - var idChain = new ResourceFieldChainExpression(idAttribute); - - FilterExpression? filter = null; - - if (ids.Count == 1) - { - var constant = new LiteralConstantExpression(ids.Single()!.ToString()!); - filter = new ComparisonExpression(ComparisonOperator.Equals, idChain, constant); - } - else if (ids.Count > 1) - { - ImmutableHashSet constants = ids.Select(id => new LiteralConstantExpression(id!.ToString()!)).ToImmutableHashSet(); - filter = new AnyExpression(idChain, constants); - } - - return LogicalExpression.Compose(LogicalOperator.And, filter, existingFilter); - } - - /// - public QueryLayer ComposeForUpdate(TId id, ResourceType primaryResourceType) - { - ArgumentGuard.NotNull(primaryResourceType, nameof(primaryResourceType)); - - IImmutableSet includeElements = _targetedFields.Relationships - .Select(relationship => new IncludeElementExpression(relationship)).ToImmutableHashSet(); - - AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResourceType); - - QueryLayer primaryLayer = ComposeTopLayer(Array.Empty(), primaryResourceType); - primaryLayer.Include = includeElements.Any() ? new IncludeExpression(includeElements) : IncludeExpression.Empty; - primaryLayer.Sort = null; - primaryLayer.Pagination = null; - primaryLayer.Filter = CreateFilterByIds(id.AsArray(), primaryIdAttribute, primaryLayer.Filter); - primaryLayer.Selection = null; - - return primaryLayer; - } - - /// - public IEnumerable<(QueryLayer, RelationshipAttribute)> ComposeForGetTargetedSecondaryResourceIds(IIdentifiable primaryResource) - { - ArgumentGuard.NotNull(primaryResource, nameof(primaryResource)); - - foreach (RelationshipAttribute relationship in _targetedFields.Relationships) - { - object? rightValue = relationship.GetValue(primaryResource); - HashSet rightResourceIds = _collectionConverter.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); - - if (rightResourceIds.Any()) - { - QueryLayer queryLayer = ComposeForGetRelationshipRightIds(relationship, rightResourceIds); - yield return (queryLayer, relationship); - } - } - } - - /// - public QueryLayer ComposeForGetRelationshipRightIds(RelationshipAttribute relationship, ICollection rightResourceIds) - { - ArgumentGuard.NotNull(relationship, nameof(relationship)); - ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); - - AttrAttribute rightIdAttribute = GetIdAttribute(relationship.RightType); - - HashSet typedIds = rightResourceIds.Select(resource => resource.GetTypedId()).ToHashSet(); - - FilterExpression? baseFilter = GetFilter(Array.Empty(), relationship.RightType); - FilterExpression? filter = CreateFilterByIds(typedIds, rightIdAttribute, baseFilter); - - var selection = new FieldSelection(); - FieldSelectors selectors = selection.GetOrCreateSelectors(relationship.RightType); - selectors.IncludeAttribute(rightIdAttribute); - - return new QueryLayer(relationship.RightType) - { - Include = IncludeExpression.Empty, - Filter = filter, - Selection = selection - }; - } - - /// - public QueryLayer ComposeForHasMany(HasManyAttribute hasManyRelationship, TId leftId, ICollection rightResourceIds) - { - ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship)); - ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); - - AttrAttribute leftIdAttribute = GetIdAttribute(hasManyRelationship.LeftType); - AttrAttribute rightIdAttribute = GetIdAttribute(hasManyRelationship.RightType); - HashSet rightTypedIds = rightResourceIds.Select(resource => resource.GetTypedId()).ToHashSet(); - - FilterExpression? leftFilter = CreateFilterByIds(leftId.AsArray(), leftIdAttribute, null); - FilterExpression? rightFilter = CreateFilterByIds(rightTypedIds, rightIdAttribute, null); - - var secondarySelection = new FieldSelection(); - FieldSelectors secondarySelectors = secondarySelection.GetOrCreateSelectors(hasManyRelationship.RightType); - secondarySelectors.IncludeAttribute(rightIdAttribute); - - QueryLayer secondaryLayer = new(hasManyRelationship.RightType) - { - Filter = rightFilter, - Selection = secondarySelection - }; - - var primarySelection = new FieldSelection(); - FieldSelectors primarySelectors = primarySelection.GetOrCreateSelectors(hasManyRelationship.LeftType); - primarySelectors.IncludeRelationship(hasManyRelationship, secondaryLayer); - primarySelectors.IncludeAttribute(leftIdAttribute); - - return new QueryLayer(hasManyRelationship.LeftType) - { - Include = new IncludeExpression(ImmutableHashSet.Create(new IncludeElementExpression(hasManyRelationship))), - Filter = leftFilter, - Selection = primarySelection - }; - } - - protected virtual IImmutableSet GetIncludeElements(IImmutableSet includeElements, - ResourceType resourceType) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - - return _resourceDefinitionAccessor.OnApplyIncludes(resourceType, includeElements); - } - - protected virtual FilterExpression? GetFilter(IReadOnlyCollection expressionsInScope, ResourceType resourceType) - { - ArgumentGuard.NotNull(expressionsInScope, nameof(expressionsInScope)); - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - - FilterExpression[] filters = expressionsInScope.OfType().ToArray(); - FilterExpression? filter = LogicalExpression.Compose(LogicalOperator.And, filters); - - return _resourceDefinitionAccessor.OnApplyFilter(resourceType, filter); - } - - protected virtual SortExpression GetSort(IReadOnlyCollection expressionsInScope, ResourceType resourceType) - { - ArgumentGuard.NotNull(expressionsInScope, nameof(expressionsInScope)); - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - - SortExpression? sort = expressionsInScope.OfType().FirstOrDefault(); - - sort = _resourceDefinitionAccessor.OnApplySort(resourceType, sort); - - if (sort == null) - { - AttrAttribute idAttribute = GetIdAttribute(resourceType); - var idAscendingSort = new SortElementExpression(new ResourceFieldChainExpression(idAttribute), true); - sort = new SortExpression(ImmutableArray.Create(idAscendingSort)); - } - - return sort; - } - - protected virtual PaginationExpression GetPagination(IReadOnlyCollection expressionsInScope, ResourceType resourceType) - { - ArgumentGuard.NotNull(expressionsInScope, nameof(expressionsInScope)); - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - - PaginationExpression? pagination = expressionsInScope.OfType().FirstOrDefault(); - - pagination = _resourceDefinitionAccessor.OnApplyPagination(resourceType, pagination); - - pagination ??= new PaginationExpression(PageNumber.ValueOne, _options.DefaultPageSize); - - return pagination; - } - -#pragma warning disable AV1130 // Return type in method signature should be an interface to an unchangeable collection - protected virtual FieldSelection? GetSelectionForSparseAttributeSet(ResourceType resourceType) -#pragma warning restore AV1130 // Return type in method signature should be an interface to an unchangeable collection - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - - var selection = new FieldSelection(); - - HashSet resourceTypes = resourceType.GetAllConcreteDerivedTypes().ToHashSet(); - resourceTypes.Add(resourceType); - - foreach (ResourceType nextType in resourceTypes) - { - IImmutableSet fieldSet = _sparseFieldSetCache.GetSparseFieldSetForQuery(nextType); - - if (!fieldSet.Any()) - { - continue; - } - - HashSet attributeSet = fieldSet.OfType().ToHashSet(); - - FieldSelectors selectors = selection.GetOrCreateSelectors(nextType); - selectors.IncludeAttributes(attributeSet); - - AttrAttribute idAttribute = GetIdAttribute(nextType); - selectors.IncludeAttribute(idAttribute); - } - - return selection.IsEmpty ? null : selection; - } - - private static AttrAttribute GetIdAttribute(ResourceType resourceType) - { - return resourceType.GetAttributeByPropertyName(nameof(Identifiable.Id)); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs deleted file mode 100644 index 14c37bb70d..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs +++ /dev/null @@ -1,96 +0,0 @@ -using System.Linq.Expressions; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources.Annotations; -using Microsoft.EntityFrameworkCore; - -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; - -/// -/// Transforms into calls. -/// -[PublicAPI] -public class IncludeClauseBuilder : QueryClauseBuilder -{ - private static readonly IncludeChainConverter IncludeChainConverter = new(); - - private readonly Expression _source; - private readonly ResourceType _resourceType; - - public IncludeClauseBuilder(Expression source, LambdaScope lambdaScope, ResourceType resourceType) - : base(lambdaScope) - { - ArgumentGuard.NotNull(source, nameof(source)); - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - - _source = source; - _resourceType = resourceType; - } - - public Expression ApplyInclude(IncludeExpression include) - { - ArgumentGuard.NotNull(include, nameof(include)); - - return Visit(include, null); - } - - public override Expression VisitInclude(IncludeExpression expression, object? argument) - { - // De-duplicate chains coming from derived relationships. - HashSet propertyPaths = new(); - - ApplyEagerLoads(_resourceType.EagerLoads, null, propertyPaths); - - foreach (ResourceFieldChainExpression chain in IncludeChainConverter.GetRelationshipChains(expression)) - { - ProcessRelationshipChain(chain, propertyPaths); - } - - return ToExpression(propertyPaths); - } - - private void ProcessRelationshipChain(ResourceFieldChainExpression chain, ISet outputPropertyPaths) - { - string? path = null; - - foreach (RelationshipAttribute relationship in chain.Fields.Cast()) - { - path = path == null ? relationship.Property.Name : $"{path}.{relationship.Property.Name}"; - - ApplyEagerLoads(relationship.RightType.EagerLoads, path, outputPropertyPaths); - } - - outputPropertyPaths.Add(path!); - } - - private void ApplyEagerLoads(IEnumerable eagerLoads, string? pathPrefix, ISet outputPropertyPaths) - { - foreach (EagerLoadAttribute eagerLoad in eagerLoads) - { - string path = pathPrefix != null ? $"{pathPrefix}.{eagerLoad.Property.Name}" : eagerLoad.Property.Name; - outputPropertyPaths.Add(path); - - ApplyEagerLoads(eagerLoad.Children, path, outputPropertyPaths); - } - } - - private Expression ToExpression(HashSet propertyPaths) - { - Expression source = _source; - - foreach (string propertyPath in propertyPaths) - { - source = IncludeExtensionMethodCall(source, propertyPath); - } - - return source; - } - - private Expression IncludeExtensionMethodCall(Expression source, string navigationPropertyPath) - { - Expression navigationExpression = Expression.Constant(navigationPropertyPath); - - return Expression.Call(typeof(EntityFrameworkQueryableExtensions), "Include", LambdaScope.Parameter.Type.AsArray(), source, navigationExpression); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaParameterNameFactory.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaParameterNameFactory.cs deleted file mode 100644 index 864b71c843..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaParameterNameFactory.cs +++ /dev/null @@ -1,49 +0,0 @@ -using Humanizer; -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; - -/// -/// Produces unique names for lambda parameters. -/// -[PublicAPI] -public sealed class LambdaParameterNameFactory -{ - private readonly HashSet _namesInScope = new(); - - public LambdaParameterNameScope Create(string typeName) - { - ArgumentGuard.NotNullNorEmpty(typeName, nameof(typeName)); - - string parameterName = typeName.Camelize(); - parameterName = EnsureNameIsUnique(parameterName); - - _namesInScope.Add(parameterName); - return new LambdaParameterNameScope(parameterName, this); - } - - private string EnsureNameIsUnique(string name) - { - if (!_namesInScope.Contains(name)) - { - return name; - } - - int counter = 1; - string alternativeName; - - do - { - counter++; - alternativeName = name + counter; - } - while (_namesInScope.Contains(alternativeName)); - - return alternativeName; - } - - public void Release(string parameterName) - { - _namesInScope.Remove(parameterName); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaParameterNameScope.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaParameterNameScope.cs deleted file mode 100644 index 2bad41d310..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaParameterNameScope.cs +++ /dev/null @@ -1,25 +0,0 @@ -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; - -[PublicAPI] -public sealed class LambdaParameterNameScope : IDisposable -{ - private readonly LambdaParameterNameFactory _owner; - - public string Name { get; } - - public LambdaParameterNameScope(string name, LambdaParameterNameFactory owner) - { - ArgumentGuard.NotNullNorEmpty(name, nameof(name)); - ArgumentGuard.NotNull(owner, nameof(owner)); - - Name = name; - _owner = owner; - } - - public void Dispose() - { - _owner.Release(Name); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs deleted file mode 100644 index e5502031a3..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Linq.Expressions; -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; - -/// -/// Contains details on a lambda expression, such as the name of the selector "x" in "x => x.Name". -/// -[PublicAPI] -public sealed class LambdaScope : IDisposable -{ - private readonly LambdaParameterNameScope _parameterNameScope; - - public ParameterExpression Parameter { get; } - public Expression Accessor { get; } - - private LambdaScope(LambdaParameterNameScope parameterNameScope, ParameterExpression parameter, Expression accessor) - { - _parameterNameScope = parameterNameScope; - Parameter = parameter; - Accessor = accessor; - } - - public static LambdaScope Create(LambdaParameterNameFactory nameFactory, Type elementType, Expression? accessorExpression) - { - ArgumentGuard.NotNull(nameFactory, nameof(nameFactory)); - ArgumentGuard.NotNull(elementType, nameof(elementType)); - - LambdaParameterNameScope parameterNameScope = nameFactory.Create(elementType.Name); - ParameterExpression parameter = Expression.Parameter(elementType, parameterNameScope.Name); - Expression accessor = accessorExpression ?? parameter; - - return new LambdaScope(parameterNameScope, parameter, accessor); - } - - public LambdaScope WithAccessor(Expression accessorExpression) - { - ArgumentGuard.NotNull(accessorExpression, nameof(accessorExpression)); - - return new LambdaScope(_parameterNameScope, Parameter, accessorExpression); - } - - public void Dispose() - { - _parameterNameScope.Dispose(); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs deleted file mode 100644 index 9c13a63d28..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Linq.Expressions; -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; - -[PublicAPI] -public sealed class LambdaScopeFactory -{ - private readonly LambdaParameterNameFactory _nameFactory; - - public LambdaScopeFactory(LambdaParameterNameFactory nameFactory) - { - ArgumentGuard.NotNull(nameFactory, nameof(nameFactory)); - - _nameFactory = nameFactory; - } - - public LambdaScope CreateScope(Type elementType, Expression? accessorExpression = null) - { - ArgumentGuard.NotNull(elementType, nameof(elementType)); - - return LambdaScope.Create(_nameFactory, elementType, accessorExpression); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/OrderClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/OrderClauseBuilder.cs deleted file mode 100644 index 7ae8dd2392..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/OrderClauseBuilder.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System.Linq.Expressions; -using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Expressions; - -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; - -/// -/// Transforms into -/// calls. -/// -[PublicAPI] -public class OrderClauseBuilder : QueryClauseBuilder -{ - private readonly Expression _source; - private readonly Type _extensionType; - - public OrderClauseBuilder(Expression source, LambdaScope lambdaScope, Type extensionType) - : base(lambdaScope) - { - ArgumentGuard.NotNull(source, nameof(source)); - ArgumentGuard.NotNull(extensionType, nameof(extensionType)); - - _source = source; - _extensionType = extensionType; - } - - public Expression ApplyOrderBy(SortExpression expression) - { - ArgumentGuard.NotNull(expression, nameof(expression)); - - return Visit(expression, null); - } - - public override Expression VisitSort(SortExpression expression, Expression? argument) - { - Expression? sortExpression = null; - - foreach (SortElementExpression sortElement in expression.Elements) - { - sortExpression = Visit(sortElement, sortExpression); - } - - return sortExpression!; - } - - public override Expression VisitSortElement(SortElementExpression expression, Expression? previousExpression) - { - Expression body = expression.Count != null ? Visit(expression.Count, null) : Visit(expression.TargetAttribute!, null); - - LambdaExpression lambda = Expression.Lambda(body, LambdaScope.Parameter); - - string operationName = GetOperationName(previousExpression != null, expression.IsAscending); - - return ExtensionMethodCall(previousExpression ?? _source, operationName, body.Type, lambda); - } - - private static string GetOperationName(bool hasPrecedingSort, bool isAscending) - { - if (hasPrecedingSort) - { - return isAscending ? "ThenBy" : "ThenByDescending"; - } - - return isAscending ? "OrderBy" : "OrderByDescending"; - } - - private Expression ExtensionMethodCall(Expression source, string operationName, Type keyType, LambdaExpression keySelector) - { - Type[] typeArguments = ArrayFactory.Create(LambdaScope.Parameter.Type, keyType); - return Expression.Call(_extensionType, operationName, typeArguments, source, keySelector); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs deleted file mode 100644 index d04ff57e9d..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs +++ /dev/null @@ -1,107 +0,0 @@ -using System.Linq.Expressions; -using System.Reflection; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; - -/// -/// Base class for transforming trees into system trees. -/// -public abstract class QueryClauseBuilder : QueryExpressionVisitor -{ - protected LambdaScope LambdaScope { get; private set; } - - protected QueryClauseBuilder(LambdaScope lambdaScope) - { - ArgumentGuard.NotNull(lambdaScope, nameof(lambdaScope)); - - LambdaScope = lambdaScope; - } - - public override Expression VisitCount(CountExpression expression, TArgument argument) - { - Expression collectionExpression = Visit(expression.TargetCollection, argument); - - Expression? propertyExpression = GetCollectionCount(collectionExpression); - - if (propertyExpression == null) - { - throw new InvalidOperationException($"Field '{expression.TargetCollection}' must be a collection."); - } - - return propertyExpression; - } - - private static Expression? GetCollectionCount(Expression? collectionExpression) - { - if (collectionExpression != null) - { - var properties = new HashSet(collectionExpression.Type.GetProperties()); - - if (collectionExpression.Type.IsInterface) - { - foreach (PropertyInfo item in collectionExpression.Type.GetInterfaces().SelectMany(@interface => @interface.GetProperties())) - { - properties.Add(item); - } - } - - foreach (PropertyInfo property in properties) - { - if (property.Name is "Count" or "Length") - { - return Expression.Property(collectionExpression, property); - } - } - } - - return null; - } - - public override Expression VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument) - { - MemberExpression? property = null; - - foreach (ResourceFieldAttribute field in expression.Fields) - { - Expression parentAccessor = property ?? LambdaScope.Accessor; - Type propertyType = field.Property.DeclaringType!; - string propertyName = field.Property.Name; - - bool requiresUpCast = parentAccessor.Type != propertyType && parentAccessor.Type.IsAssignableFrom(propertyType); - Type parentType = requiresUpCast ? propertyType : parentAccessor.Type; - - if (parentType.GetProperty(propertyName) == null) - { - throw new InvalidOperationException($"Type '{parentType.Name}' does not contain a property named '{propertyName}'."); - } - - property = requiresUpCast - ? Expression.MakeMemberAccess(Expression.Convert(parentAccessor, propertyType), field.Property) - : Expression.Property(parentAccessor, propertyName); - } - - return property!; - } - - protected TResult WithLambdaScopeAccessor(Expression accessorExpression, Func action) - { - ArgumentGuard.NotNull(accessorExpression, nameof(accessorExpression)); - ArgumentGuard.NotNull(action, nameof(action)); - - LambdaScope backupScope = LambdaScope; - - try - { - using (LambdaScope = LambdaScope.WithAccessor(accessorExpression)) - { - return action(); - } - } - finally - { - LambdaScope = backupScope; - } - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs deleted file mode 100644 index d571ac1dce..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs +++ /dev/null @@ -1,116 +0,0 @@ -using System.Linq.Expressions; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources; -using Microsoft.EntityFrameworkCore.Metadata; - -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; - -/// -/// Drives conversion from into system trees. -/// -[PublicAPI] -public class QueryableBuilder -{ - private readonly Expression _source; - private readonly Type _elementType; - private readonly Type _extensionType; - private readonly LambdaParameterNameFactory _nameFactory; - private readonly IResourceFactory _resourceFactory; - private readonly IModel _entityModel; - private readonly LambdaScopeFactory _lambdaScopeFactory; - - public QueryableBuilder(Expression source, Type elementType, Type extensionType, LambdaParameterNameFactory nameFactory, IResourceFactory resourceFactory, - IModel entityModel, LambdaScopeFactory? lambdaScopeFactory = null) - { - ArgumentGuard.NotNull(source, nameof(source)); - ArgumentGuard.NotNull(elementType, nameof(elementType)); - ArgumentGuard.NotNull(extensionType, nameof(extensionType)); - ArgumentGuard.NotNull(nameFactory, nameof(nameFactory)); - ArgumentGuard.NotNull(resourceFactory, nameof(resourceFactory)); - ArgumentGuard.NotNull(entityModel, nameof(entityModel)); - - _source = source; - _elementType = elementType; - _extensionType = extensionType; - _nameFactory = nameFactory; - _resourceFactory = resourceFactory; - _entityModel = entityModel; - _lambdaScopeFactory = lambdaScopeFactory ?? new LambdaScopeFactory(_nameFactory); - } - - public virtual Expression ApplyQuery(QueryLayer layer) - { - ArgumentGuard.NotNull(layer, nameof(layer)); - - Expression expression = _source; - - if (layer.Include != null) - { - expression = ApplyInclude(expression, layer.Include, layer.ResourceType); - } - - if (layer.Filter != null) - { - expression = ApplyFilter(expression, layer.Filter); - } - - if (layer.Sort != null) - { - expression = ApplySort(expression, layer.Sort); - } - - if (layer.Pagination != null) - { - expression = ApplyPagination(expression, layer.Pagination); - } - - if (layer.Selection is { IsEmpty: false }) - { - expression = ApplySelection(expression, layer.Selection, layer.ResourceType); - } - - return expression; - } - - protected virtual Expression ApplyInclude(Expression source, IncludeExpression include, ResourceType resourceType) - { - using LambdaScope lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); - - var builder = new IncludeClauseBuilder(source, lambdaScope, resourceType); - return builder.ApplyInclude(include); - } - - protected virtual Expression ApplyFilter(Expression source, FilterExpression filter) - { - using LambdaScope lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); - - var builder = new WhereClauseBuilder(source, lambdaScope, _extensionType, _nameFactory); - return builder.ApplyWhere(filter); - } - - protected virtual Expression ApplySort(Expression source, SortExpression sort) - { - using LambdaScope lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); - - var builder = new OrderClauseBuilder(source, lambdaScope, _extensionType); - return builder.ApplyOrderBy(sort); - } - - protected virtual Expression ApplyPagination(Expression source, PaginationExpression pagination) - { - using LambdaScope lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); - - var builder = new SkipTakeClauseBuilder(source, lambdaScope, _extensionType); - return builder.ApplySkipTake(pagination); - } - - protected virtual Expression ApplySelection(Expression source, FieldSelection selection, ResourceType resourceType) - { - using LambdaScope lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); - - var builder = new SelectClauseBuilder(source, lambdaScope, _entityModel, _extensionType, _nameFactory, _resourceFactory); - return builder.ApplySelect(selection, resourceType); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs deleted file mode 100644 index 690c49de24..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs +++ /dev/null @@ -1,286 +0,0 @@ -using System.Linq.Expressions; -using System.Reflection; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using Microsoft.EntityFrameworkCore.Metadata; - -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; - -/// -/// Transforms into -/// calls. -/// -[PublicAPI] -public class SelectClauseBuilder : QueryClauseBuilder -{ - private static readonly MethodInfo TypeGetTypeMethod = typeof(object).GetMethod("GetType")!; - private static readonly MethodInfo TypeOpEqualityMethod = typeof(Type).GetMethod("op_Equality")!; - private static readonly CollectionConverter CollectionConverter = new(); - private static readonly ConstantExpression NullConstant = Expression.Constant(null); - - private readonly Expression _source; - private readonly IModel _entityModel; - private readonly Type _extensionType; - private readonly LambdaParameterNameFactory _nameFactory; - private readonly IResourceFactory _resourceFactory; - - public SelectClauseBuilder(Expression source, LambdaScope lambdaScope, IModel entityModel, Type extensionType, LambdaParameterNameFactory nameFactory, - IResourceFactory resourceFactory) - : base(lambdaScope) - { - ArgumentGuard.NotNull(source, nameof(source)); - ArgumentGuard.NotNull(entityModel, nameof(entityModel)); - ArgumentGuard.NotNull(extensionType, nameof(extensionType)); - ArgumentGuard.NotNull(nameFactory, nameof(nameFactory)); - ArgumentGuard.NotNull(resourceFactory, nameof(resourceFactory)); - - _source = source; - _entityModel = entityModel; - _extensionType = extensionType; - _nameFactory = nameFactory; - _resourceFactory = resourceFactory; - } - - public Expression ApplySelect(FieldSelection selection, ResourceType resourceType) - { - ArgumentGuard.NotNull(selection, nameof(selection)); - - Expression bodyInitializer = CreateLambdaBodyInitializer(selection, resourceType, LambdaScope, false); - - LambdaExpression lambda = Expression.Lambda(bodyInitializer, LambdaScope.Parameter); - - return SelectExtensionMethodCall(_source, LambdaScope.Parameter.Type, lambda); - } - - private Expression CreateLambdaBodyInitializer(FieldSelection selection, ResourceType resourceType, LambdaScope lambdaScope, - bool lambdaAccessorRequiresTestForNull) - { - IEntityType entityType = _entityModel.FindEntityType(resourceType.ClrType)!; - IEntityType[] concreteEntityTypes = entityType.GetConcreteDerivedTypesInclusive().ToArray(); - - Expression bodyInitializer = concreteEntityTypes.Length > 1 - ? CreateLambdaBodyInitializerForTypeHierarchy(selection, resourceType, concreteEntityTypes, lambdaScope) - : CreateLambdaBodyInitializerForSingleType(selection, resourceType, lambdaScope); - - if (!lambdaAccessorRequiresTestForNull) - { - return bodyInitializer; - } - - return TestForNull(lambdaScope.Accessor, bodyInitializer); - } - - private Expression CreateLambdaBodyInitializerForTypeHierarchy(FieldSelection selection, ResourceType baseResourceType, - IEnumerable concreteEntityTypes, LambdaScope lambdaScope) - { - IReadOnlySet resourceTypes = selection.GetResourceTypes(); - Expression rootCondition = lambdaScope.Accessor; - - foreach (IEntityType entityType in concreteEntityTypes) - { - ResourceType? resourceType = resourceTypes.SingleOrDefault(type => type.ClrType == entityType.ClrType); - - if (resourceType != null) - { - FieldSelectors fieldSelectors = selection.GetOrCreateSelectors(resourceType); - - if (!fieldSelectors.IsEmpty) - { - ICollection propertySelectors = ToPropertySelectors(fieldSelectors, resourceType, entityType.ClrType); - - MemberBinding[] propertyAssignments = propertySelectors.Select(selector => CreatePropertyAssignment(selector, lambdaScope)) - .Cast().ToArray(); - - NewExpression createInstance = _resourceFactory.CreateNewExpression(entityType.ClrType); - MemberInitExpression memberInit = Expression.MemberInit(createInstance, propertyAssignments); - UnaryExpression castToBaseType = Expression.Convert(memberInit, baseResourceType.ClrType); - - BinaryExpression typeCheck = CreateRuntimeTypeCheck(lambdaScope, entityType.ClrType); - rootCondition = Expression.Condition(typeCheck, castToBaseType, rootCondition); - } - } - } - - return rootCondition; - } - - private static BinaryExpression CreateRuntimeTypeCheck(LambdaScope lambdaScope, Type concreteClrType) - { - // Emitting "resource.GetType() == typeof(Article)" instead of "resource is Article" so we don't need to check for most-derived - // types first. This way, we can fallback to "anything else" at the end without worrying about order. - - Expression concreteTypeConstant = concreteClrType.CreateTupleAccessExpressionForConstant(typeof(Type)); - MethodCallExpression getTypeCall = Expression.Call(lambdaScope.Accessor, TypeGetTypeMethod); - - return Expression.MakeBinary(ExpressionType.Equal, getTypeCall, concreteTypeConstant, false, TypeOpEqualityMethod); - } - - private Expression CreateLambdaBodyInitializerForSingleType(FieldSelection selection, ResourceType resourceType, LambdaScope lambdaScope) - { - FieldSelectors fieldSelectors = selection.GetOrCreateSelectors(resourceType); - ICollection propertySelectors = ToPropertySelectors(fieldSelectors, resourceType, lambdaScope.Accessor.Type); - - MemberBinding[] propertyAssignments = - propertySelectors.Select(selector => CreatePropertyAssignment(selector, lambdaScope)).Cast().ToArray(); - - NewExpression createInstance = _resourceFactory.CreateNewExpression(lambdaScope.Accessor.Type); - return Expression.MemberInit(createInstance, propertyAssignments); - } - - private ICollection ToPropertySelectors(FieldSelectors fieldSelectors, ResourceType resourceType, Type elementType) - { - var propertySelectors = new Dictionary(); - - if (fieldSelectors.ContainsReadOnlyAttribute || fieldSelectors.ContainsOnlyRelationships) - { - // If a read-only attribute is selected, its calculated value likely depends on another property, so select all properties. - // And only selecting relationships implicitly means to select all attributes too. - - IncludeAllAttributes(elementType, propertySelectors); - } - - IncludeFields(fieldSelectors, propertySelectors); - IncludeEagerLoads(resourceType, propertySelectors); - - return propertySelectors.Values; - } - - private void IncludeAllAttributes(Type elementType, Dictionary propertySelectors) - { - IEntityType entityModel = _entityModel.GetEntityTypes().Single(type => type.ClrType == elementType); - IEnumerable entityProperties = entityModel.GetProperties().Where(property => !property.IsShadowProperty()).ToArray(); - - foreach (IProperty entityProperty in entityProperties) - { - var propertySelector = new PropertySelector(entityProperty.PropertyInfo!); - IncludeWritableProperty(propertySelector, propertySelectors); - } - } - - private static void IncludeFields(FieldSelectors fieldSelectors, Dictionary propertySelectors) - { - foreach ((ResourceFieldAttribute resourceField, QueryLayer? queryLayer) in fieldSelectors) - { - var propertySelector = new PropertySelector(resourceField.Property, queryLayer); - IncludeWritableProperty(propertySelector, propertySelectors); - } - } - - private static void IncludeWritableProperty(PropertySelector propertySelector, Dictionary propertySelectors) - { - if (propertySelector.Property.SetMethod != null) - { - propertySelectors[propertySelector.Property] = propertySelector; - } - } - - private static void IncludeEagerLoads(ResourceType resourceType, Dictionary propertySelectors) - { - foreach (EagerLoadAttribute eagerLoad in resourceType.EagerLoads) - { - var propertySelector = new PropertySelector(eagerLoad.Property); - - // When an entity navigation property is decorated with both EagerLoadAttribute and RelationshipAttribute, - // it may already exist with a sub-layer. So do not overwrite in that case. - if (!propertySelectors.ContainsKey(propertySelector.Property)) - { - propertySelectors[propertySelector.Property] = propertySelector; - } - } - } - - private MemberAssignment CreatePropertyAssignment(PropertySelector propertySelector, LambdaScope lambdaScope) - { - bool requiresUpCast = lambdaScope.Accessor.Type != propertySelector.Property.DeclaringType && - lambdaScope.Accessor.Type.IsAssignableFrom(propertySelector.Property.DeclaringType); - - MemberExpression propertyAccess = requiresUpCast - ? Expression.MakeMemberAccess(Expression.Convert(lambdaScope.Accessor, propertySelector.Property.DeclaringType!), propertySelector.Property) - : Expression.Property(lambdaScope.Accessor, propertySelector.Property); - - Expression assignmentRightHandSide = propertyAccess; - - if (propertySelector.NextLayer != null) - { - var lambdaScopeFactory = new LambdaScopeFactory(_nameFactory); - - assignmentRightHandSide = CreateAssignmentRightHandSideForLayer(propertySelector.NextLayer, lambdaScope, propertyAccess, propertySelector.Property, - lambdaScopeFactory); - } - - return Expression.Bind(propertySelector.Property, assignmentRightHandSide); - } - - private Expression CreateAssignmentRightHandSideForLayer(QueryLayer layer, LambdaScope outerLambdaScope, MemberExpression propertyAccess, - PropertyInfo selectorPropertyInfo, LambdaScopeFactory lambdaScopeFactory) - { - Type? collectionElementType = CollectionConverter.FindCollectionElementType(selectorPropertyInfo.PropertyType); - Type bodyElementType = collectionElementType ?? selectorPropertyInfo.PropertyType; - - if (collectionElementType != null) - { - return CreateCollectionInitializer(outerLambdaScope, selectorPropertyInfo, bodyElementType, layer, lambdaScopeFactory); - } - - if (layer.Selection == null || layer.Selection.IsEmpty) - { - return propertyAccess; - } - - using LambdaScope scope = lambdaScopeFactory.CreateScope(bodyElementType, propertyAccess); - return CreateLambdaBodyInitializer(layer.Selection, layer.ResourceType, scope, true); - } - - private Expression CreateCollectionInitializer(LambdaScope lambdaScope, PropertyInfo collectionProperty, Type elementType, QueryLayer layer, - LambdaScopeFactory lambdaScopeFactory) - { - MemberExpression propertyExpression = Expression.Property(lambdaScope.Accessor, collectionProperty); - - var builder = new QueryableBuilder(propertyExpression, elementType, typeof(Enumerable), _nameFactory, _resourceFactory, _entityModel, - lambdaScopeFactory); - - Expression layerExpression = builder.ApplyQuery(layer); - - string operationName = CollectionConverter.TypeCanContainHashSet(collectionProperty.PropertyType) ? "ToHashSet" : "ToList"; - return CopyCollectionExtensionMethodCall(layerExpression, operationName, elementType); - } - - private static Expression TestForNull(Expression expressionToTest, Expression ifFalseExpression) - { - BinaryExpression equalsNull = Expression.Equal(expressionToTest, NullConstant); - return Expression.Condition(equalsNull, Expression.Convert(NullConstant, expressionToTest.Type), ifFalseExpression); - } - - private static Expression CopyCollectionExtensionMethodCall(Expression source, string operationName, Type elementType) - { - return Expression.Call(typeof(Enumerable), operationName, elementType.AsArray(), source); - } - - private Expression SelectExtensionMethodCall(Expression source, Type elementType, Expression selectBody) - { - Type[] typeArguments = ArrayFactory.Create(elementType, elementType); - return Expression.Call(_extensionType, "Select", typeArguments, source, selectBody); - } - - private sealed class PropertySelector - { - public PropertyInfo Property { get; } - public QueryLayer? NextLayer { get; } - - public PropertySelector(PropertyInfo property, QueryLayer? nextLayer = null) - { - ArgumentGuard.NotNull(property, nameof(property)); - - Property = property; - NextLayer = nextLayer; - } - - public override string ToString() - { - return $"Property: {(NextLayer != null ? $"{Property.Name}..." : Property.Name)}"; - } - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SkipTakeClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SkipTakeClauseBuilder.cs deleted file mode 100644 index 4bb9bfd6f5..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SkipTakeClauseBuilder.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Linq.Expressions; -using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Expressions; - -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; - -/// -/// Transforms into and -/// calls. -/// -[PublicAPI] -public class SkipTakeClauseBuilder : QueryClauseBuilder -{ - private readonly Expression _source; - private readonly Type _extensionType; - - public SkipTakeClauseBuilder(Expression source, LambdaScope lambdaScope, Type extensionType) - : base(lambdaScope) - { - ArgumentGuard.NotNull(source, nameof(source)); - ArgumentGuard.NotNull(extensionType, nameof(extensionType)); - - _source = source; - _extensionType = extensionType; - } - - public Expression ApplySkipTake(PaginationExpression expression) - { - ArgumentGuard.NotNull(expression, nameof(expression)); - - return Visit(expression, null); - } - - public override Expression VisitPagination(PaginationExpression expression, object? argument) - { - Expression skipTakeExpression = _source; - - if (expression.PageSize != null) - { - int skipValue = (expression.PageNumber.OneBasedValue - 1) * expression.PageSize.Value; - - if (skipValue > 0) - { - skipTakeExpression = ExtensionMethodCall(skipTakeExpression, "Skip", skipValue); - } - - skipTakeExpression = ExtensionMethodCall(skipTakeExpression, "Take", expression.PageSize.Value); - } - - return skipTakeExpression; - } - - private Expression ExtensionMethodCall(Expression source, string operationName, int value) - { - Expression constant = value.CreateTupleAccessExpressionForConstant(typeof(int)); - - return Expression.Call(_extensionType, operationName, LambdaScope.Parameter.Type.AsArray(), source, constant); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs deleted file mode 100644 index 2806e96da4..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs +++ /dev/null @@ -1,303 +0,0 @@ -using System.Collections; -using System.Linq.Expressions; -using JetBrains.Annotations; -using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources.Internal; - -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding; - -/// -/// Transforms into -/// calls. -/// -[PublicAPI] -public class WhereClauseBuilder : QueryClauseBuilder -{ - private static readonly CollectionConverter CollectionConverter = new(); - private static readonly ConstantExpression NullConstant = Expression.Constant(null); - - private readonly Expression _source; - private readonly Type _extensionType; - private readonly LambdaParameterNameFactory _nameFactory; - - public WhereClauseBuilder(Expression source, LambdaScope lambdaScope, Type extensionType, LambdaParameterNameFactory nameFactory) - : base(lambdaScope) - { - ArgumentGuard.NotNull(source, nameof(source)); - ArgumentGuard.NotNull(extensionType, nameof(extensionType)); - ArgumentGuard.NotNull(nameFactory, nameof(nameFactory)); - - _source = source; - _extensionType = extensionType; - _nameFactory = nameFactory; - } - - public Expression ApplyWhere(FilterExpression filter) - { - ArgumentGuard.NotNull(filter, nameof(filter)); - - LambdaExpression lambda = GetPredicateLambda(filter); - - return WhereExtensionMethodCall(lambda); - } - - private LambdaExpression GetPredicateLambda(FilterExpression filter) - { - Expression body = Visit(filter, null); - return Expression.Lambda(body, LambdaScope.Parameter); - } - - private Expression WhereExtensionMethodCall(LambdaExpression predicate) - { - return Expression.Call(_extensionType, "Where", LambdaScope.Parameter.Type.AsArray(), _source, predicate); - } - - public override Expression VisitHas(HasExpression expression, Type? argument) - { - Expression property = Visit(expression.TargetCollection, argument); - - Type? elementType = CollectionConverter.FindCollectionElementType(property.Type); - - if (elementType == null) - { - throw new InvalidOperationException("Expression must be a collection."); - } - - Expression? predicate = null; - - if (expression.Filter != null) - { - var lambdaScopeFactory = new LambdaScopeFactory(_nameFactory); - using LambdaScope lambdaScope = lambdaScopeFactory.CreateScope(elementType); - - var builder = new WhereClauseBuilder(property, lambdaScope, typeof(Enumerable), _nameFactory); - predicate = builder.GetPredicateLambda(expression.Filter); - } - - return AnyExtensionMethodCall(elementType, property, predicate); - } - - private static MethodCallExpression AnyExtensionMethodCall(Type elementType, Expression source, Expression? predicate) - { - return predicate != null - ? Expression.Call(typeof(Enumerable), "Any", elementType.AsArray(), source, predicate) - : Expression.Call(typeof(Enumerable), "Any", elementType.AsArray(), source); - } - - public override Expression VisitIsType(IsTypeExpression expression, Type? argument) - { - Expression property = expression.TargetToOneRelationship != null ? Visit(expression.TargetToOneRelationship, argument) : LambdaScope.Accessor; - TypeBinaryExpression typeCheck = Expression.TypeIs(property, expression.DerivedType.ClrType); - - if (expression.Child == null) - { - return typeCheck; - } - - UnaryExpression derivedAccessor = Expression.Convert(property, expression.DerivedType.ClrType); - Expression filter = WithLambdaScopeAccessor(derivedAccessor, () => Visit(expression.Child, argument)); - - return Expression.AndAlso(typeCheck, filter); - } - - public override Expression VisitMatchText(MatchTextExpression expression, Type? argument) - { - Expression property = Visit(expression.TargetAttribute, argument); - - if (property.Type != typeof(string)) - { - throw new InvalidOperationException("Expression must be a string."); - } - - Expression text = Visit(expression.TextValue, property.Type); - - if (expression.MatchKind == TextMatchKind.StartsWith) - { - return Expression.Call(property, "StartsWith", null, text); - } - - if (expression.MatchKind == TextMatchKind.EndsWith) - { - return Expression.Call(property, "EndsWith", null, text); - } - - return Expression.Call(property, "Contains", null, text); - } - - public override Expression VisitAny(AnyExpression expression, Type? argument) - { - Expression property = Visit(expression.TargetAttribute, argument); - - var valueList = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(property.Type))!; - - foreach (LiteralConstantExpression constant in expression.Constants) - { - object? value = ConvertTextToTargetType(constant.Value, property.Type); - valueList.Add(value); - } - - ConstantExpression collection = Expression.Constant(valueList); - return ContainsExtensionMethodCall(collection, property); - } - - private static Expression ContainsExtensionMethodCall(Expression collection, Expression value) - { - return Expression.Call(typeof(Enumerable), "Contains", value.Type.AsArray(), collection, value); - } - - public override Expression VisitLogical(LogicalExpression expression, Type? argument) - { - var termQueue = new Queue(expression.Terms.Select(filter => Visit(filter, argument))); - - if (expression.Operator == LogicalOperator.And) - { - return Compose(termQueue, Expression.AndAlso); - } - - if (expression.Operator == LogicalOperator.Or) - { - return Compose(termQueue, Expression.OrElse); - } - - throw new InvalidOperationException($"Unknown logical operator '{expression.Operator}'."); - } - - private static BinaryExpression Compose(Queue argumentQueue, Func applyOperator) - { - Expression left = argumentQueue.Dequeue(); - Expression right = argumentQueue.Dequeue(); - - BinaryExpression tempExpression = applyOperator(left, right); - - while (argumentQueue.Any()) - { - Expression nextArgument = argumentQueue.Dequeue(); - tempExpression = applyOperator(tempExpression, nextArgument); - } - - return tempExpression; - } - - public override Expression VisitNot(NotExpression expression, Type? argument) - { - Expression child = Visit(expression.Child, argument); - return Expression.Not(child); - } - - public override Expression VisitComparison(ComparisonExpression expression, Type? argument) - { - Type commonType = ResolveCommonType(expression.Left, expression.Right); - - Expression left = WrapInConvert(Visit(expression.Left, commonType), commonType); - Expression right = WrapInConvert(Visit(expression.Right, commonType), commonType); - - switch (expression.Operator) - { - case ComparisonOperator.Equals: - { - return Expression.Equal(left, right); - } - case ComparisonOperator.LessThan: - { - return Expression.LessThan(left, right); - } - case ComparisonOperator.LessOrEqual: - { - return Expression.LessThanOrEqual(left, right); - } - case ComparisonOperator.GreaterThan: - { - return Expression.GreaterThan(left, right); - } - case ComparisonOperator.GreaterOrEqual: - { - return Expression.GreaterThanOrEqual(left, right); - } - } - - throw new InvalidOperationException($"Unknown comparison operator '{expression.Operator}'."); - } - - private Type ResolveCommonType(QueryExpression left, QueryExpression right) - { - Type leftType = ResolveFixedType(left); - - if (RuntimeTypeConverter.CanContainNull(leftType)) - { - return leftType; - } - - if (right is NullConstantExpression) - { - return typeof(Nullable<>).MakeGenericType(leftType); - } - - Type? rightType = TryResolveFixedType(right); - - if (rightType != null && RuntimeTypeConverter.CanContainNull(rightType)) - { - return rightType; - } - - return leftType; - } - - private Type ResolveFixedType(QueryExpression expression) - { - Expression result = Visit(expression, null); - return result.Type; - } - - private Type? TryResolveFixedType(QueryExpression expression) - { - if (expression is CountExpression) - { - return typeof(int); - } - - if (expression is ResourceFieldChainExpression chain) - { - Expression child = Visit(chain, null); - return child.Type; - } - - return null; - } - - private static Expression WrapInConvert(Expression expression, Type? targetType) - { - try - { - return targetType != null && expression.Type != targetType ? Expression.Convert(expression, targetType) : expression; - } - catch (InvalidOperationException exception) - { - throw new InvalidQueryException("Query creation failed due to incompatible types.", exception); - } - } - - public override Expression VisitNullConstant(NullConstantExpression expression, Type? expressionType) - { - return NullConstant; - } - - public override Expression VisitLiteralConstant(LiteralConstantExpression expression, Type? expressionType) - { - object? convertedValue = expressionType != null ? ConvertTextToTargetType(expression.Value, expressionType) : expression.Value; - - return convertedValue.CreateTupleAccessExpressionForConstant(expressionType ?? typeof(string)); - } - - private static object? ConvertTextToTargetType(string text, Type targetType) - { - try - { - return RuntimeTypeConverter.ConvertType(text, targetType); - } - catch (FormatException exception) - { - throw new InvalidQueryException("Query creation failed due to incompatible types.", exception); - } - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs b/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs deleted file mode 100644 index 0cad7968c4..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs +++ /dev/null @@ -1,165 +0,0 @@ -using System.Collections.Concurrent; -using System.Collections.Immutable; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal; - -/// -public sealed class SparseFieldSetCache : ISparseFieldSetCache -{ - private static readonly ConcurrentDictionary ViewableFieldSetCache = new(); - - private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; - private readonly Lazy>> _lazySourceTable; - private readonly IDictionary> _visitedTable; - - public SparseFieldSetCache(IEnumerable constraintProviders, IResourceDefinitionAccessor resourceDefinitionAccessor) - { - ArgumentGuard.NotNull(constraintProviders, nameof(constraintProviders)); - ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); - - _resourceDefinitionAccessor = resourceDefinitionAccessor; - _lazySourceTable = new Lazy>>(() => BuildSourceTable(constraintProviders)); - _visitedTable = new Dictionary>(); - } - - private static IDictionary> BuildSourceTable(IEnumerable constraintProviders) - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - KeyValuePair[] sparseFieldTables = constraintProviders - .SelectMany(provider => provider.GetConstraints()) - .Where(constraint => constraint.Scope == null) - .Select(constraint => constraint.Expression) - .OfType() - .Select(expression => expression.Table) - .SelectMany(table => table) - .ToArray(); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - var mergedTable = new Dictionary.Builder>(); - - foreach ((ResourceType resourceType, SparseFieldSetExpression sparseFieldSet) in sparseFieldTables) - { - if (!mergedTable.ContainsKey(resourceType)) - { - mergedTable[resourceType] = ImmutableHashSet.CreateBuilder(); - } - - AddSparseFieldsToSet(sparseFieldSet.Fields, mergedTable[resourceType]); - } - - return mergedTable.ToDictionary(pair => pair.Key, pair => (IImmutableSet)pair.Value.ToImmutable()); - } - - private static void AddSparseFieldsToSet(IImmutableSet sparseFieldsToAdd, - ImmutableHashSet.Builder sparseFieldSetBuilder) - { - foreach (ResourceFieldAttribute field in sparseFieldsToAdd) - { - sparseFieldSetBuilder.Add(field); - } - } - - /// - public IImmutableSet GetSparseFieldSetForQuery(ResourceType resourceType) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - - if (!_visitedTable.ContainsKey(resourceType)) - { - SparseFieldSetExpression? inputExpression = _lazySourceTable.Value.TryGetValue(resourceType, out IImmutableSet? inputFields) - ? new SparseFieldSetExpression(inputFields) - : null; - - SparseFieldSetExpression? outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceType, inputExpression); - - IImmutableSet outputFields = outputExpression == null - ? ImmutableHashSet.Empty - : outputExpression.Fields; - - _visitedTable[resourceType] = outputFields; - } - - return _visitedTable[resourceType]; - } - - /// - public IImmutableSet GetIdAttributeSetForRelationshipQuery(ResourceType resourceType) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - - AttrAttribute idAttribute = resourceType.GetAttributeByPropertyName(nameof(Identifiable.Id)); - var inputExpression = new SparseFieldSetExpression(ImmutableHashSet.Create(idAttribute)); - - // Intentionally not cached, as we are fetching ID only (ignoring any sparse fieldset that came from query string). - SparseFieldSetExpression? outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceType, inputExpression); - - ImmutableHashSet outputAttributes = outputExpression == null - ? ImmutableHashSet.Empty - : outputExpression.Fields.OfType().ToImmutableHashSet(); - - outputAttributes = outputAttributes.Add(idAttribute); - return outputAttributes; - } - - /// - public IImmutableSet GetSparseFieldSetForSerializer(ResourceType resourceType) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - - if (!_visitedTable.ContainsKey(resourceType)) - { - SparseFieldSetExpression inputExpression = _lazySourceTable.Value.TryGetValue(resourceType, out IImmutableSet? inputFields) - ? new SparseFieldSetExpression(inputFields) - : GetCachedViewableFieldSet(resourceType); - - SparseFieldSetExpression? outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceType, inputExpression); - - IImmutableSet outputFields = outputExpression == null - ? GetCachedViewableFieldSet(resourceType).Fields - : inputExpression.Fields.Intersect(outputExpression.Fields); - - _visitedTable[resourceType] = outputFields; - } - - return _visitedTable[resourceType]; - } - - private static SparseFieldSetExpression GetCachedViewableFieldSet(ResourceType resourceType) - { - if (!ViewableFieldSetCache.TryGetValue(resourceType, out SparseFieldSetExpression? fieldSet)) - { - IImmutableSet viewableFields = GetViewableFields(resourceType); - fieldSet = new SparseFieldSetExpression(viewableFields); - ViewableFieldSetCache[resourceType] = fieldSet; - } - - return fieldSet; - } - - private static IImmutableSet GetViewableFields(ResourceType resourceType) - { - ImmutableHashSet.Builder fieldSetBuilder = ImmutableHashSet.CreateBuilder(); - - foreach (AttrAttribute attribute in resourceType.Attributes.Where(attr => attr.Capabilities.HasFlag(AttrCapabilities.AllowView))) - { - fieldSetBuilder.Add(attribute); - } - - fieldSetBuilder.AddRange(resourceType.Relationships); - - return fieldSetBuilder.ToImmutable(); - } - - public void Reset() - { - _visitedTable.Clear(); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/SystemExpressionExtensions.cs b/src/JsonApiDotNetCore/Queries/Internal/SystemExpressionExtensions.cs deleted file mode 100644 index a314d5f20a..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/SystemExpressionExtensions.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Linq.Expressions; -using System.Reflection; - -namespace JsonApiDotNetCore.Queries.Internal; - -internal static class SystemExpressionExtensions -{ - public static Expression CreateTupleAccessExpressionForConstant(this object? value, Type type) - { - // To enable efficient query plan caching, inline constants (that vary per request) should be converted into query parameters. - // https://stackoverflow.com/questions/54075758/building-a-parameterized-entityframework-core-expression - - // This method can be used to change a query like: - // SELECT ... FROM ... WHERE x."Age" = 3 - // into: - // SELECT ... FROM ... WHERE x."Age" = @p0 - - // The code below builds the next expression for a type T that is unknown at compile time: - // Expression.Property(Expression.Constant(Tuple.Create(value)), "Item1") - // Which represents the next C# code: - // Tuple.Create(value).Item1; - - MethodInfo tupleCreateUnboundMethod = typeof(Tuple).GetMethods() - .Single(method => method.Name == "Create" && method.IsGenericMethod && method.GetGenericArguments().Length == 1); - - MethodInfo tupleCreateClosedMethod = tupleCreateUnboundMethod.MakeGenericMethod(type); - - ConstantExpression constantExpression = Expression.Constant(value, type); - - MethodCallExpression tupleCreateCall = Expression.Call(tupleCreateClosedMethod, constantExpression); - return Expression.Property(tupleCreateCall, "Item1"); - } -} 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/Parsing/ConstantValueConverter.cs b/src/JsonApiDotNetCore/Queries/Parsing/ConstantValueConverter.cs new file mode 100644 index 0000000000..d624964ddd --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/ConstantValueConverter.cs @@ -0,0 +1,15 @@ +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// Converts a constant value within a query string parameter to an . +/// +/// +/// The constant value to convert from. +/// +/// +/// The zero-based position of in the query string parameter value. +/// +/// +/// The converted object instance. +/// +public delegate object ConstantValueConverter(string value, int position); diff --git a/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs new file mode 100644 index 0000000000..f89bf3dc39 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/FilterParser.cs @@ -0,0 +1,607 @@ +using System.Collections.Immutable; +using Humanizer; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings.FieldChains; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +[PublicAPI] +public class FilterParser : QueryExpressionParser, IFilterParser +{ + private static readonly HashSet FilterKeywords = + [ + Keywords.Not, + Keywords.And, + Keywords.Or, + Keywords.Equals, + Keywords.GreaterThan, + Keywords.GreaterOrEqual, + Keywords.LessThan, + Keywords.LessOrEqual, + Keywords.Contains, + Keywords.StartsWith, + Keywords.EndsWith, + Keywords.Any, + Keywords.Count, + Keywords.Has, + Keywords.IsType + ]; + + private readonly IResourceFactory _resourceFactory; + private readonly Stack _resourceTypeStack = new(); + + /// + /// Gets the resource type currently in scope. Call to temporarily change the current resource type. + /// + protected ResourceType ResourceTypeInScope + { + get + { + if (_resourceTypeStack.Count == 0) + { + throw new InvalidOperationException("No resource type is currently in scope. Call Parse() first."); + } + + return _resourceTypeStack.Peek(); + } + } + + public FilterParser(IResourceFactory resourceFactory) + { + ArgumentNullException.ThrowIfNull(resourceFactory); + + _resourceFactory = resourceFactory; + } + + /// + public FilterExpression Parse(string source, ResourceType resourceType) + { + ArgumentNullException.ThrowIfNull(resourceType); + + Tokenize(source); + + _resourceTypeStack.Clear(); + FilterExpression expression; + + using (InScopeOfResourceType(resourceType)) + { + expression = ParseFilter(); + + AssertTokenStackIsEmpty(); + } + + AssertResourceTypeStackIsEmpty(); + + return expression; + } + + protected virtual bool IsFunction(string name) + { + ArgumentException.ThrowIfNullOrEmpty(name); + + return name == Keywords.Count || FilterKeywords.Contains(name); + } + + protected virtual FunctionExpression ParseFunction() + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Text) + { + switch (nextToken.Value) + { + case Keywords.Count: + { + return ParseCount(); + } + } + } + + return ParseFilter(); + } + + private CountExpression ParseCount() + { + EatText(Keywords.Count); + EatSingleCharacterToken(TokenKind.OpenParen); + + ResourceFieldChainExpression targetCollection = + ParseFieldChain(BuiltInPatterns.ToOneChainEndingInToMany, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new CountExpression(targetCollection); + } + + protected virtual FilterExpression ParseFilter() + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Text) + { + switch (nextToken.Value) + { + case Keywords.Not: + { + return ParseNot(); + } + case Keywords.And: + case Keywords.Or: + { + return ParseLogical(nextToken.Value); + } + case Keywords.Equals: + case Keywords.LessThan: + case Keywords.LessOrEqual: + case Keywords.GreaterThan: + case Keywords.GreaterOrEqual: + { + return ParseComparison(nextToken.Value); + } + case Keywords.Contains: + case Keywords.StartsWith: + case Keywords.EndsWith: + { + return ParseTextMatch(nextToken.Value); + } + case Keywords.Any: + { + return ParseAny(); + } + case Keywords.Has: + { + return ParseHas(); + } + case Keywords.IsType: + { + return ParseIsType(); + } + } + } + + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException("Filter function expected.", position); + } + + protected virtual NotExpression ParseNot() + { + EatText(Keywords.Not); + EatSingleCharacterToken(TokenKind.OpenParen); + + FilterExpression child = ParseFilter(); + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new NotExpression(child); + } + + protected virtual LogicalExpression ParseLogical(string operatorName) + { + ArgumentException.ThrowIfNullOrEmpty(operatorName); + + EatText(operatorName); + EatSingleCharacterToken(TokenKind.OpenParen); + + ImmutableArray.Builder termsBuilder = ImmutableArray.CreateBuilder(); + + FilterExpression term = ParseFilter(); + termsBuilder.Add(term); + + EatSingleCharacterToken(TokenKind.Comma); + + term = ParseFilter(); + termsBuilder.Add(term); + + while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) + { + EatSingleCharacterToken(TokenKind.Comma); + + term = ParseFilter(); + termsBuilder.Add(term); + } + + EatSingleCharacterToken(TokenKind.CloseParen); + + var logicalOperator = Enum.Parse(operatorName.Pascalize()); + return new LogicalExpression(logicalOperator, termsBuilder.ToImmutable()); + } + + protected virtual ComparisonExpression ParseComparison(string operatorName) + { + ArgumentException.ThrowIfNullOrEmpty(operatorName); + + var comparisonOperator = Enum.Parse(operatorName.Pascalize()); + + EatText(operatorName); + EatSingleCharacterToken(TokenKind.OpenParen); + + QueryExpression leftTerm = ParseComparisonLeftTerm(comparisonOperator); + + EatSingleCharacterToken(TokenKind.Comma); + + QueryExpression rightTerm = ParseComparisonRightTerm(leftTerm); + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new ComparisonExpression(comparisonOperator, leftTerm, rightTerm); + } + + private QueryExpression ParseComparisonLeftTerm(ComparisonOperator comparisonOperator) + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text } && IsFunction(nextToken.Value!)) + { + return ParseFunction(); + } + + // Allow equality comparison of a to-one relationship with null. + FieldChainPattern pattern = comparisonOperator == ComparisonOperator.Equals + ? BuiltInPatterns.ToOneChainEndingInAttributeOrToOne + : BuiltInPatterns.ToOneChainEndingInAttribute; + + return ParseFieldChain(pattern, FieldChainPatternMatchOptions.None, ResourceTypeInScope, "Function or field name expected."); + } + + private QueryExpression ParseComparisonRightTerm(QueryExpression leftTerm) + { + if (leftTerm is ResourceFieldChainExpression leftFieldChain) + { + ResourceFieldAttribute leftLastField = leftFieldChain.Fields[^1]; + + if (leftLastField is HasOneAttribute) + { + return ParseNull(); + } + + var leftAttribute = (AttrAttribute)leftLastField; + + ConstantValueConverter constantValueConverter = GetConstantValueConverterForAttribute(leftAttribute); + return ParseTypedComparisonRightTerm(leftAttribute.Property.PropertyType, constantValueConverter); + } + + if (leftTerm is FunctionExpression leftFunction) + { + ConstantValueConverter constantValueConverter = GetConstantValueConverterForType(leftFunction.ReturnType); + return ParseTypedComparisonRightTerm(leftFunction.ReturnType, constantValueConverter); + } + + throw new InvalidOperationException( + $"Internal error: Expected left term to be a function or field chain, instead of '{leftTerm.GetType().Name}': '{leftTerm}'."); + } + + private QueryExpression ParseTypedComparisonRightTerm(Type leftType, ConstantValueConverter constantValueConverter) + { + bool allowNull = RuntimeTypeConverter.CanContainNull(leftType); + + string errorMessage = + allowNull ? "Function, field name, value between quotes or null expected." : "Function, field name or value between quotes expected."; + + if (TokenStack.TryPeek(out Token? nextToken)) + { + if (nextToken is { Kind: TokenKind.QuotedText }) + { + TokenStack.Pop(); + + object constantValue = constantValueConverter(nextToken.Value!, nextToken.Position); + return new LiteralConstantExpression(constantValue, nextToken.Value!); + } + + if (nextToken.Kind == TokenKind.Text) + { + if (nextToken.Value == Keywords.Null) + { + if (!allowNull) + { + throw new QueryParseException(errorMessage, nextToken.Position); + } + + TokenStack.Pop(); + return NullConstantExpression.Instance; + } + + if (IsFunction(nextToken.Value!)) + { + return ParseFunction(); + } + + return ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, errorMessage); + } + } + + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException(errorMessage, position); + } + + protected virtual MatchTextExpression ParseTextMatch(string operatorName) + { + ArgumentException.ThrowIfNullOrEmpty(operatorName); + + EatText(operatorName); + EatSingleCharacterToken(TokenKind.OpenParen); + + int chainStartPosition = GetNextTokenPositionOrEnd(); + + ResourceFieldChainExpression targetAttributeChain = + ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + + var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1]; + + if (targetAttribute.Property.PropertyType != typeof(string)) + { + int position = chainStartPosition + GetRelativePositionOfLastFieldInChain(targetAttributeChain); + throw new QueryParseException("Attribute of type 'String' expected.", position); + } + + EatSingleCharacterToken(TokenKind.Comma); + + ConstantValueConverter constantValueConverter = GetConstantValueConverterForAttribute(targetAttribute); + LiteralConstantExpression constant = ParseConstant(constantValueConverter); + + EatSingleCharacterToken(TokenKind.CloseParen); + + var matchKind = Enum.Parse(operatorName.Pascalize()); + return new MatchTextExpression(targetAttributeChain, constant, matchKind); + } + + protected virtual AnyExpression ParseAny() + { + EatText(Keywords.Any); + EatSingleCharacterToken(TokenKind.OpenParen); + + ResourceFieldChainExpression targetAttributeChain = + ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + + var targetAttribute = (AttrAttribute)targetAttributeChain.Fields[^1]; + + EatSingleCharacterToken(TokenKind.Comma); + + ImmutableHashSet.Builder constantsBuilder = ImmutableHashSet.CreateBuilder(); + + ConstantValueConverter constantValueConverter = GetConstantValueConverterForAttribute(targetAttribute); + LiteralConstantExpression constant = ParseConstant(constantValueConverter); + constantsBuilder.Add(constant); + + while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) + { + EatSingleCharacterToken(TokenKind.Comma); + + constant = ParseConstant(constantValueConverter); + constantsBuilder.Add(constant); + } + + EatSingleCharacterToken(TokenKind.CloseParen); + + IImmutableSet constantSet = constantsBuilder.ToImmutable(); + + return new AnyExpression(targetAttributeChain, constantSet); + } + + protected virtual HasExpression ParseHas() + { + EatText(Keywords.Has); + EatSingleCharacterToken(TokenKind.OpenParen); + + ResourceFieldChainExpression targetCollection = + ParseFieldChain(BuiltInPatterns.ToOneChainEndingInToMany, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + + FilterExpression? filter = null; + + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) + { + EatSingleCharacterToken(TokenKind.Comma); + + var hasManyRelationship = (HasManyAttribute)targetCollection.Fields[^1]; + + using (InScopeOfResourceType(hasManyRelationship.RightType)) + { + filter = ParseFilter(); + } + } + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new HasExpression(targetCollection, filter); + } + + protected virtual IsTypeExpression ParseIsType() + { + EatText(Keywords.IsType); + EatSingleCharacterToken(TokenKind.OpenParen); + + ResourceFieldChainExpression? targetToOneRelationship = TryParseToOneRelationshipChain(); + + EatSingleCharacterToken(TokenKind.Comma); + + ResourceType baseType = targetToOneRelationship != null ? ((RelationshipAttribute)targetToOneRelationship.Fields[^1]).RightType : ResourceTypeInScope; + ResourceType derivedType = ParseDerivedType(baseType); + + FilterExpression? child = TryParseFilterInIsType(derivedType); + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new IsTypeExpression(targetToOneRelationship, derivedType, child); + } + + private ResourceFieldChainExpression? TryParseToOneRelationshipChain() + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) + { + return null; + } + + return ParseFieldChain(BuiltInPatterns.ToOneChain, FieldChainPatternMatchOptions.None, ResourceTypeInScope, "Relationship name or , expected."); + } + + private ResourceType ParseDerivedType(ResourceType baseType) + { + int position = GetNextTokenPositionOrEnd(); + + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) + { + string derivedTypeName = token.Value!; + return ResolveDerivedType(baseType, derivedTypeName, token.Position); + } + + throw new QueryParseException("Resource type expected.", position); + } + + private static ResourceType ResolveDerivedType(ResourceType baseType, string derivedTypeName, int position) + { + ResourceType? derivedType = GetDerivedType(baseType, derivedTypeName); + + if (derivedType == null) + { + throw new QueryParseException($"Resource type '{derivedTypeName}' does not exist or does not derive from '{baseType.PublicName}'.", position); + } + + return derivedType; + } + + private static ResourceType? GetDerivedType(ResourceType baseType, string publicName) + { + foreach (ResourceType derivedType in baseType.DirectlyDerivedTypes) + { + if (derivedType.PublicName == publicName) + { + return derivedType; + } + + ResourceType? nextType = GetDerivedType(derivedType, publicName); + + if (nextType != null) + { + return nextType; + } + } + + return null; + } + + private FilterExpression? TryParseFilterInIsType(ResourceType derivedType) + { + FilterExpression? filter = null; + + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Comma) + { + EatSingleCharacterToken(TokenKind.Comma); + + using (InScopeOfResourceType(derivedType)) + { + filter = ParseFilter(); + } + } + + return filter; + } + + private LiteralConstantExpression ParseConstant(ConstantValueConverter constantValueConverter) + { + int position = GetNextTokenPositionOrEnd(); + + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.QuotedText) + { + object constantValue = constantValueConverter(token.Value!, token.Position); + return new LiteralConstantExpression(constantValue, token.Value!); + } + + throw new QueryParseException("Value between quotes expected.", position); + } + + private NullConstantExpression ParseNull() + { + int position = GetNextTokenPositionOrEnd(); + + if (TokenStack.TryPop(out Token? token) && token is { Kind: TokenKind.Text, Value: Keywords.Null }) + { + return NullConstantExpression.Instance; + } + + throw new QueryParseException("null expected.", position); + } + + protected virtual ConstantValueConverter GetConstantValueConverterForType(Type destinationType) + { + ArgumentNullException.ThrowIfNull(destinationType); + + return (stringValue, position) => + { + try + { + return RuntimeTypeConverter.ConvertType(stringValue, destinationType)!; + } + catch (FormatException exception) + { + throw new QueryParseException($"Failed to convert '{stringValue}' of type 'String' to type '{destinationType.Name}'.", position, exception); + } + }; + } + + private ConstantValueConverter GetConstantValueConverterForAttribute(AttrAttribute attribute) + { + if (attribute is { Property.Name: nameof(Identifiable.Id) }) + { + return (stringValue, position) => + { + try + { + return DeObfuscateStringId(attribute.Type, stringValue); + } + catch (JsonApiException exception) + { + throw new QueryParseException(exception.Errors[0].Detail!, position); + } + }; + } + + return GetConstantValueConverterForType(attribute.Property.PropertyType); + } + + private object DeObfuscateStringId(ResourceType resourceType, string stringId) + { + IIdentifiable tempResource = _resourceFactory.CreateInstance(resourceType.ClrType); + tempResource.StringId = stringId; + return tempResource.GetTypedId(); + } + + protected override void ValidateField(ResourceFieldAttribute field, int position) + { + ArgumentNullException.ThrowIfNull(field); + + if (field.IsFilterBlocked()) + { + string kind = field is AttrAttribute ? "attribute" : "relationship"; + throw new QueryParseException($"Filtering on {kind} '{field.PublicName}' is not allowed.", position); + } + } + + /// + /// Changes the resource type currently in scope and restores the original resource type when the return value is disposed. + /// + protected IDisposable InScopeOfResourceType(ResourceType resourceType) + { + ArgumentNullException.ThrowIfNull(resourceType); + + _resourceTypeStack.Push(resourceType); + return new PopResourceTypeOnDispose(_resourceTypeStack); + } + + private void AssertResourceTypeStackIsEmpty() + { + if (_resourceTypeStack.Count > 0) + { + throw new InvalidOperationException("There is still a resource type in scope after parsing has completed. " + + $"Verify that {nameof(IDisposable.Dispose)}() is called on all return values of {nameof(InScopeOfResourceType)}()."); + } + } + + private sealed class PopResourceTypeOnDispose(Stack resourceTypeStack) : IDisposable + { + private readonly Stack _resourceTypeStack = resourceTypeStack; + + public void Dispose() + { + _resourceTypeStack.Pop(); + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/IFilterParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/IFilterParser.cs new file mode 100644 index 0000000000..289a745027 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/IFilterParser.cs @@ -0,0 +1,21 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// Parses the JSON:API 'filter' query string parameter value. +/// +public interface IFilterParser +{ + /// + /// Parses the specified source into a . Throws a if the input is invalid. + /// + /// + /// The source text to read from. + /// + /// + /// The resource type used to lookup JSON:API fields that are referenced in . + /// + FilterExpression Parse(string source, ResourceType resourceType); +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/IIncludeParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/IIncludeParser.cs new file mode 100644 index 0000000000..2524d1ef4c --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/IIncludeParser.cs @@ -0,0 +1,21 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// Parses the JSON:API 'include' query string parameter value. +/// +public interface IIncludeParser +{ + /// + /// Parses the specified source into an . Throws a if the input is invalid. + /// + /// + /// The source text to read from. + /// + /// + /// The resource type used to lookup JSON:API fields that are referenced in . + /// + IncludeExpression Parse(string source, ResourceType resourceType); +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/IPaginationParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/IPaginationParser.cs new file mode 100644 index 0000000000..bd15ac2b3c --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/IPaginationParser.cs @@ -0,0 +1,27 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// Parses the JSON:API 'page' query string parameter value. +/// +public interface IPaginationParser +{ + /// + /// Parses the specified source into a . Throws a if the input is + /// invalid. + /// + /// + /// The source text to read from. + /// + /// + /// The resource type used to lookup JSON:API fields that are referenced in . + /// + /// + /// Due to the syntax of the JSON:API pagination parameter, The returned is an intermediate value + /// that gets converted into by . + /// + PaginationQueryStringValueExpression Parse(string source, ResourceType resourceType); +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/IQueryStringParameterScopeParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/IQueryStringParameterScopeParser.cs new file mode 100644 index 0000000000..22cd2b1426 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/IQueryStringParameterScopeParser.cs @@ -0,0 +1,30 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings.FieldChains; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// Parses the JSON:API 'sort' and 'filter' query string parameter names, which contain a resource field chain that indicates the scope its query string +/// parameter value applies to. +/// +public interface IQueryStringParameterScopeParser +{ + /// + /// Parses the specified source into a . Throws a if the input is + /// invalid. + /// + /// + /// The source text to read from. + /// + /// + /// The resource type used to lookup JSON:API fields that are referenced in . + /// + /// + /// The pattern that the field chain in must match. + /// + /// + /// The match options for . + /// + QueryStringParameterScopeExpression Parse(string source, ResourceType resourceType, FieldChainPattern pattern, FieldChainPatternMatchOptions options); +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/ISortParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/ISortParser.cs new file mode 100644 index 0000000000..be5f72545f --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/ISortParser.cs @@ -0,0 +1,21 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// Parses the JSON:API 'sort' query string parameter value. +/// +public interface ISortParser +{ + /// + /// Parses the specified source into a . Throws a if the input is invalid. + /// + /// + /// The source text to read from. + /// + /// + /// The resource type used to lookup JSON:API fields that are referenced in . + /// + SortExpression Parse(string source, ResourceType resourceType); +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/ISparseFieldSetParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/ISparseFieldSetParser.cs new file mode 100644 index 0000000000..acec82b8f2 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/ISparseFieldSetParser.cs @@ -0,0 +1,21 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// Parses the JSON:API 'fields' query string parameter value. +/// +public interface ISparseFieldSetParser +{ + /// + /// Parses the specified source into a . Throws a if the input is invalid. + /// + /// + /// The source text to read from. + /// + /// + /// The resource type used to lookup JSON:API fields that are referenced in . + /// + SparseFieldSetExpression? Parse(string source, ResourceType resourceType); +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/ISparseFieldTypeParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/ISparseFieldTypeParser.cs new file mode 100644 index 0000000000..fd5cc0aec5 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/ISparseFieldTypeParser.cs @@ -0,0 +1,17 @@ +using JsonApiDotNetCore.Configuration; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// Parses the JSON:API 'fields' query string parameter name. +/// +public interface ISparseFieldTypeParser +{ + /// + /// Parses the specified source into a . Throws a if the input is invalid. + /// + /// + /// The source text to read from. + /// + ResourceType Parse(string source); +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/IncludeParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/IncludeParser.cs new file mode 100644 index 0000000000..eb328f3183 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/IncludeParser.cs @@ -0,0 +1,317 @@ +using System.Collections.Immutable; +using System.Collections.ObjectModel; +using System.Text; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +[PublicAPI] +public class IncludeParser : QueryExpressionParser, IIncludeParser +{ + private readonly IJsonApiOptions _options; + + public IncludeParser(IJsonApiOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + _options = options; + } + + /// + public IncludeExpression Parse(string source, ResourceType resourceType) + { + ArgumentNullException.ThrowIfNull(resourceType); + + Tokenize(source); + + IncludeExpression expression = ParseInclude(source, resourceType); + + AssertTokenStackIsEmpty(); + ValidateMaximumIncludeDepth(expression, 0); + + return expression; + } + + protected virtual IncludeExpression ParseInclude(string source, ResourceType resourceType) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(resourceType); + + var treeRoot = IncludeTreeNode.CreateRoot(resourceType); + bool isAtStart = true; + + while (TokenStack.Count > 0) + { + if (!isAtStart) + { + EatSingleCharacterToken(TokenKind.Comma); + } + else + { + isAtStart = false; + } + + ParseRelationshipChain(source, treeRoot); + } + + return treeRoot.ToExpression(); + } + + private void ParseRelationshipChain(string source, IncludeTreeNode treeRoot) + { + // A relationship name usually matches a single relationship, even when overridden in derived types. + // But in the following case, two relationships are matched on GET /shoppingBaskets?include=items: + // + // public abstract class ShoppingBasket : Identifiable + // { + // } + // + // public sealed class SilverShoppingBasket : ShoppingBasket + // { + // [HasMany] + // public ISet
Items { get; get; } + // } + // + // public sealed class PlatinumShoppingBasket : ShoppingBasket + // { + // [HasMany] + // public ISet Items { get; get; } + // } + // + // Now if the include chain has subsequent relationships, we need to scan both Items relationships for matches, + // which is why ParseRelationshipName returns a collection. + // + // The advantage of this unfolding is we don't require callers to upcast in relationship chains. The downside is + // that there's currently no way to include Products without Articles. We could add such optional upcast syntax + // in the future, if desired. + + ReadOnlyCollection children = ParseRelationshipName(source, [treeRoot]); + + while (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Period) + { + EatSingleCharacterToken(TokenKind.Period); + + children = ParseRelationshipName(source, children); + } + } + + private ReadOnlyCollection ParseRelationshipName(string source, IReadOnlyCollection parents) + { + int position = GetNextTokenPositionOrEnd(); + + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) + { + return LookupRelationshipName(token.Value!, parents, source, position); + } + + throw new QueryParseException("Relationship name expected.", position); + } + + private static ReadOnlyCollection LookupRelationshipName(string relationshipName, IReadOnlyCollection parents, + string source, int position) + { + List children = []; + HashSet relationshipsFound = []; + + foreach (IncludeTreeNode parent in parents) + { + // Depending on the left side of the include chain, we may match relationships anywhere in the resource type hierarchy. + // This is compensated for when rendering the response, which substitutes relationships on base types with the derived ones. + HashSet relationships = GetRelationshipsInConcreteTypes(parent.Relationship.RightType, relationshipName); + + if (relationships.Count > 0) + { + relationshipsFound.UnionWith(relationships); + + RelationshipAttribute[] relationshipsToInclude = relationships.Where(relationship => !relationship.IsIncludeBlocked()).ToArray(); + ReadOnlyCollection affectedChildren = parent.EnsureChildren(relationshipsToInclude); + children.AddRange(affectedChildren); + } + } + + AssertRelationshipsFound(relationshipsFound, relationshipName, parents, position); + AssertAtLeastOneCanBeIncluded(relationshipsFound, relationshipName, source, position); + + return children.AsReadOnly(); + } + + private static HashSet GetRelationshipsInConcreteTypes(ResourceType resourceType, string relationshipName) + { + HashSet relationshipsToInclude = []; + + foreach (RelationshipAttribute relationship in resourceType.GetRelationshipsInTypeOrDerived(relationshipName)) + { + if (!relationship.LeftType.ClrType.IsAbstract) + { + relationshipsToInclude.Add(relationship); + } + + IncludeRelationshipsFromConcreteDerivedTypes(relationship, relationshipsToInclude); + } + + return relationshipsToInclude; + } + + private static void IncludeRelationshipsFromConcreteDerivedTypes(RelationshipAttribute relationship, HashSet relationshipsToInclude) + { + foreach (ResourceType derivedType in relationship.LeftType.GetAllConcreteDerivedTypes()) + { + RelationshipAttribute relationshipInDerived = derivedType.GetRelationshipByPublicName(relationship.PublicName); + relationshipsToInclude.Add(relationshipInDerived); + } + } + + private static void AssertRelationshipsFound(HashSet relationshipsFound, string relationshipName, + IReadOnlyCollection parents, int position) + { + if (relationshipsFound.Count > 0) + { + return; + } + + ResourceType[] parentResourceTypes = parents.Select(parent => parent.Relationship.RightType).Distinct().ToArray(); + + bool hasDerivedTypes = parents.Any(parent => parent.Relationship.RightType.DirectlyDerivedTypes.Count > 0); + + string message = GetErrorMessageForNoneFound(relationshipName, parentResourceTypes, hasDerivedTypes); + throw new QueryParseException(message, position); + } + + private static string GetErrorMessageForNoneFound(string relationshipName, ResourceType[] parentResourceTypes, bool hasDerivedTypes) + { + var builder = new StringBuilder($"Relationship '{relationshipName}'"); + + if (parentResourceTypes.Length == 1) + { + builder.Append($" does not exist on resource type '{parentResourceTypes.First().PublicName}'"); + } + else + { + string typeNames = string.Join(", ", parentResourceTypes.Select(type => $"'{type.PublicName}'")); + builder.Append($" does not exist on any of the resource types {typeNames}"); + } + + builder.Append(hasDerivedTypes ? " or any of its derived types." : "."); + + return builder.ToString(); + } + + private static void AssertAtLeastOneCanBeIncluded(HashSet relationshipsFound, string relationshipName, string source, int position) + { + if (relationshipsFound.All(relationship => relationship.IsIncludeBlocked())) + { + ResourceType resourceType = relationshipsFound.First().LeftType; + string message = $"Including the relationship '{relationshipName}' on '{resourceType}' is not allowed."; + + var exception = new QueryParseException(message, position); + string specificMessage = exception.GetMessageWithPosition(source); + + throw new InvalidQueryStringParameterException("include", "The specified include is invalid.", specificMessage); + } + } + + private void ValidateMaximumIncludeDepth(IncludeExpression include, int position) + { + if (_options.MaximumIncludeDepth != null) + { + int maximumDepth = _options.MaximumIncludeDepth.Value; + Stack parentChain = new(); + + foreach (IncludeElementExpression element in include.Elements) + { + ThrowIfMaximumDepthExceeded(element, parentChain, maximumDepth, position); + } + } + } + + private static void ThrowIfMaximumDepthExceeded(IncludeElementExpression includeElement, Stack parentChain, int maximumDepth, + int position) + { + parentChain.Push(includeElement.Relationship); + + if (parentChain.Count > maximumDepth) + { + string path = string.Join('.', parentChain.Reverse().Select(relationship => relationship.PublicName)); + throw new QueryParseException($"Including '{path}' exceeds the maximum inclusion depth of {maximumDepth}.", position); + } + + foreach (IncludeElementExpression child in includeElement.Children) + { + ThrowIfMaximumDepthExceeded(child, parentChain, maximumDepth, position); + } + + parentChain.Pop(); + } + + private sealed class IncludeTreeNode + { + private readonly Dictionary _children = []; + + public RelationshipAttribute Relationship { get; } + + private IncludeTreeNode(RelationshipAttribute relationship) + { + Relationship = relationship; + } + + public static IncludeTreeNode CreateRoot(ResourceType resourceType) + { + var relationship = new HiddenRootRelationshipAttribute(resourceType); + return new IncludeTreeNode(relationship); + } + + public ReadOnlyCollection EnsureChildren(RelationshipAttribute[] relationships) + { + foreach (RelationshipAttribute relationship in relationships) + { + if (!_children.ContainsKey(relationship)) + { + var newChild = new IncludeTreeNode(relationship); + _children.Add(relationship, newChild); + } + } + + return _children.Where(pair => relationships.Contains(pair.Key)).Select(pair => pair.Value).ToArray().AsReadOnly(); + } + + public IncludeExpression ToExpression() + { + IncludeElementExpression element = ToElementExpression(); + + if (element.Relationship is HiddenRootRelationshipAttribute) + { + return element.Children.Count > 0 ? new IncludeExpression(element.Children) : IncludeExpression.Empty; + } + + return new IncludeExpression(ImmutableHashSet.Create(element)); + } + + private IncludeElementExpression ToElementExpression() + { + IImmutableSet elementChildren = _children.Values.Select(child => child.ToElementExpression()).ToImmutableHashSet(); + return new IncludeElementExpression(Relationship, elementChildren); + } + + public override string ToString() + { + IncludeExpression include = ToExpression(); + return include.ToFullString(); + } + + private sealed class HiddenRootRelationshipAttribute : RelationshipAttribute + { + public HiddenRootRelationshipAttribute(ResourceType rightType) + { + ArgumentNullException.ThrowIfNull(rightType); + + RightType = rightType; + PublicName = "<>"; + } + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/Keywords.cs b/src/JsonApiDotNetCore/Queries/Parsing/Keywords.cs similarity index 94% rename from src/JsonApiDotNetCore/Queries/Internal/Parsing/Keywords.cs rename to src/JsonApiDotNetCore/Queries/Parsing/Keywords.cs index 790f8f544d..66538117fd 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/Keywords.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/Keywords.cs @@ -3,7 +3,7 @@ #pragma warning disable AV1008 // Class should not be static #pragma warning disable AV1010 // Member hides inherited member -namespace JsonApiDotNetCore.Queries.Internal.Parsing; +namespace JsonApiDotNetCore.Queries.Parsing; [PublicAPI] public static class Keywords diff --git a/src/JsonApiDotNetCore/Queries/Parsing/PaginationParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/PaginationParser.cs new file mode 100644 index 0000000000..669fba7b9c --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/PaginationParser.cs @@ -0,0 +1,104 @@ +using System.Collections.Immutable; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings.FieldChains; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +[PublicAPI] +public class PaginationParser : QueryExpressionParser, IPaginationParser +{ + /// + public PaginationQueryStringValueExpression Parse(string source, ResourceType resourceType) + { + ArgumentNullException.ThrowIfNull(resourceType); + + Tokenize(source); + + PaginationQueryStringValueExpression expression = ParsePagination(resourceType); + + AssertTokenStackIsEmpty(); + + return expression; + } + + protected virtual PaginationQueryStringValueExpression ParsePagination(ResourceType resourceType) + { + ArgumentNullException.ThrowIfNull(resourceType); + + ImmutableArray.Builder elementsBuilder = + ImmutableArray.CreateBuilder(); + + PaginationElementQueryStringValueExpression element = ParsePaginationElement(resourceType); + elementsBuilder.Add(element); + + while (TokenStack.Count > 0) + { + EatSingleCharacterToken(TokenKind.Comma); + + element = ParsePaginationElement(resourceType); + elementsBuilder.Add(element); + } + + return new PaginationQueryStringValueExpression(elementsBuilder.ToImmutable()); + } + + protected virtual PaginationElementQueryStringValueExpression ParsePaginationElement(ResourceType resourceType) + { + ArgumentNullException.ThrowIfNull(resourceType); + + int position = GetNextTokenPositionOrEnd(); + int? number = TryParseNumber(); + + if (number != null) + { + return new PaginationElementQueryStringValueExpression(null, number.Value, position); + } + + ResourceFieldChainExpression scope = ParseFieldChain(BuiltInPatterns.RelationshipChainEndingInToMany, FieldChainPatternMatchOptions.None, resourceType, + "Number or relationship name expected."); + + EatSingleCharacterToken(TokenKind.Colon); + + position = GetNextTokenPositionOrEnd(); + number = TryParseNumber(); + + if (number == null) + { + throw new QueryParseException("Number expected.", position); + } + + return new PaginationElementQueryStringValueExpression(scope, number.Value, position); + } + + private int? TryParseNumber() + { + if (TokenStack.TryPeek(out Token? nextToken)) + { + int number; + + if (nextToken.Kind == TokenKind.Minus) + { + TokenStack.Pop(); + int position = GetNextTokenPositionOrEnd(); + + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text && int.TryParse(token.Value, out number)) + { + return -number; + } + + throw new QueryParseException("Digits expected.", position); + } + + if (nextToken.Kind == TokenKind.Text && int.TryParse(nextToken.Value, out number)) + { + TokenStack.Pop(); + return number; + } + } + + return null; + } +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs new file mode 100644 index 0000000000..772be38530 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/QueryExpressionParser.cs @@ -0,0 +1,189 @@ +using System.Collections.Immutable; +using System.Text; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings.FieldChains; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// The base class for parsing query string parameters, using the Recursive Descent algorithm. +/// +/// +/// A tokenizer populates a stack of tokens from the source text, which is then recursively popped by various parsing routines. A +/// is expected to be thrown on invalid input. +/// +[PublicAPI] +public abstract class QueryExpressionParser +{ + private int _endOfSourcePosition; + + /// + /// Contains the tokens produced from the source text, after has been called. + /// + /// + /// The various parsing methods typically pop tokens while producing s. + /// + protected Stack TokenStack { get; private set; } = new(); + + /// + /// Enables derived types to throw a when usage of a JSON:API field inside a field chain is not permitted. + /// + protected virtual void ValidateField(ResourceFieldAttribute field, int position) + { + } + + /// + /// Populates from the source text using . + /// + /// + /// To use a custom tokenizer, override this method and consider overriding . + /// + protected virtual void Tokenize(string source) + { + ArgumentNullException.ThrowIfNull(source); + + var tokenizer = new QueryTokenizer(source); + TokenStack = new Stack(tokenizer.EnumerateTokens().Reverse()); + _endOfSourcePosition = source.Length; + } + + /// + /// Parses a dot-separated path of field names into a chain of resource fields, while matching it against the specified pattern. + /// + protected ResourceFieldChainExpression ParseFieldChain(FieldChainPattern pattern, FieldChainPatternMatchOptions options, ResourceType resourceType, + string? alternativeErrorMessage) + { + ArgumentNullException.ThrowIfNull(pattern); + ArgumentNullException.ThrowIfNull(resourceType); + + int startPosition = GetNextTokenPositionOrEnd(); + + string path = EatFieldChain(alternativeErrorMessage); + PatternMatchResult result = pattern.Match(path, resourceType, options); + + if (!result.IsSuccess) + { + string message = result.IsFieldChainError + ? result.FailureMessage + : $"Field chain on resource type '{resourceType}' failed to match the pattern: {pattern.GetDescription()}. {result.FailureMessage}"; + + throw new QueryParseException(message, startPosition + result.FailurePosition); + } + + int chainPosition = 0; + + foreach (ResourceFieldAttribute field in result.FieldChain) + { + ValidateField(field, startPosition + chainPosition); + + chainPosition += field.PublicName.Length + 1; + } + + return new ResourceFieldChainExpression(result.FieldChain.ToImmutableArray()); + } + + private string EatFieldChain(string? alternativeErrorMessage) + { + var pathBuilder = new StringBuilder(); + + while (true) + { + int position = GetNextTokenPositionOrEnd(); + + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text && token.Value != Keywords.Null) + { + pathBuilder.Append(token.Value); + + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Period) + { + EatSingleCharacterToken(TokenKind.Period); + pathBuilder.Append('.'); + } + else + { + return pathBuilder.ToString(); + } + } + else + { + throw new QueryParseException(alternativeErrorMessage ?? "Field name expected.", position); + } + } + } + + /// + /// Consumes a token containing the expected text from the top of . Throws a if a different + /// token kind is at the top, it contains a different text, or if there are no more tokens available. + /// + protected void EatText(string text) + { + ArgumentNullException.ThrowIfNull(text); + + if (!TokenStack.TryPop(out Token? token) || token.Kind != TokenKind.Text || token.Value != text) + { + int position = token?.Position ?? GetNextTokenPositionOrEnd(); + throw new QueryParseException($"{text} expected.", position); + } + } + + /// + /// Consumes the expected token kind from the top of . Throws a if a different token kind is + /// at the top, or if there are no more tokens available. + /// + protected virtual void EatSingleCharacterToken(TokenKind kind) + { + if (!TokenStack.TryPop(out Token? token) || token.Kind != kind) + { + char ch = QueryTokenizer.SingleCharacterToTokenKinds.Single(pair => pair.Value == kind).Key; + int position = token?.Position ?? GetNextTokenPositionOrEnd(); + throw new QueryParseException($"{ch} expected.", position); + } + } + + /// + /// Gets the zero-based position of the token at the top of , or the position at the end of the source text if there are no more + /// tokens available. + /// + protected int GetNextTokenPositionOrEnd() + { + if (TokenStack.TryPeek(out Token? nextToken)) + { + return nextToken.Position; + } + + return _endOfSourcePosition; + } + + /// + /// Gets the zero-based position of the last field in the specified resource field chain. + /// + protected int GetRelativePositionOfLastFieldInChain(ResourceFieldChainExpression fieldChain) + { + ArgumentNullException.ThrowIfNull(fieldChain); + + int position = 0; + + for (int index = 0; index < fieldChain.Fields.Count - 1; index++) + { + position += fieldChain.Fields[index].PublicName.Length + 1; + } + + return position; + } + + /// + /// Throws a when isn't empty. Derived types should call this when parsing has completed, to + /// ensure all input has been processed. + /// + protected void AssertTokenStackIsEmpty() + { + if (TokenStack.Count > 0) + { + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException("End of expression expected.", position); + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/QueryParseException.cs b/src/JsonApiDotNetCore/Queries/Parsing/QueryParseException.cs new file mode 100644 index 0000000000..4f63f45e24 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/QueryParseException.cs @@ -0,0 +1,43 @@ +using System.Text; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +/// The error that is thrown when parsing a query string parameter fails. +/// +[PublicAPI] +public sealed class QueryParseException : Exception +{ + /// + /// Gets the zero-based position in the text of the query string parameter name/value, or at its end, where the failure occurred, or -1 if unavailable. + /// + public int Position { get; } + + public QueryParseException(string message, int position) + : base(message) + { + Position = position; + } + + public QueryParseException(string message, int position, Exception innerException) + : base(message, innerException) + { + Position = position; + } + + public string GetMessageWithPosition(string sourceText) + { + ArgumentNullException.ThrowIfNull(sourceText); + + if (Position < 0) + { + return Message; + } + + StringBuilder builder = new(); + builder.Append(Message); + builder.Append($" Failed at position {Position + 1}: {sourceText[..Position]}^{sourceText[Position..]}"); + return builder.ToString(); + } +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/QueryStringParameterScopeParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/QueryStringParameterScopeParser.cs new file mode 100644 index 0000000000..dfb98dc612 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/QueryStringParameterScopeParser.cs @@ -0,0 +1,55 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings.FieldChains; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +[PublicAPI] +public class QueryStringParameterScopeParser : QueryExpressionParser, IQueryStringParameterScopeParser +{ + /// + public QueryStringParameterScopeExpression Parse(string source, ResourceType resourceType, FieldChainPattern pattern, FieldChainPatternMatchOptions options) + { + ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(pattern); + + Tokenize(source); + + QueryStringParameterScopeExpression expression = ParseQueryStringParameterScope(resourceType, pattern, options); + + AssertTokenStackIsEmpty(); + + return expression; + } + + protected virtual QueryStringParameterScopeExpression ParseQueryStringParameterScope(ResourceType resourceType, FieldChainPattern pattern, + FieldChainPatternMatchOptions options) + { + ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(pattern); + + int position = GetNextTokenPositionOrEnd(); + + if (!TokenStack.TryPop(out Token? token) || token.Kind != TokenKind.Text) + { + throw new QueryParseException("Parameter name expected.", position); + } + + var name = new LiteralConstantExpression(token.Value!); + + ResourceFieldChainExpression? scope = null; + + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.OpenBracket) + { + TokenStack.Pop(); + + scope = ParseFieldChain(pattern, options, resourceType, null); + + EatSingleCharacterToken(TokenKind.CloseBracket); + } + + return new QueryStringParameterScopeExpression(name, scope); + } +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs b/src/JsonApiDotNetCore/Queries/Parsing/QueryTokenizer.cs similarity index 78% rename from src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs rename to src/JsonApiDotNetCore/Queries/Parsing/QueryTokenizer.cs index 3f04ce92aa..0e1e7660a2 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/QueryTokenizer.cs @@ -2,7 +2,7 @@ using System.Text; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Queries.Internal.Parsing; +namespace JsonApiDotNetCore.Queries.Parsing; [PublicAPI] public sealed class QueryTokenizer @@ -22,12 +22,13 @@ public sealed class QueryTokenizer private readonly string _source; private readonly StringBuilder _textBuffer = new(); - private int _offset; + private int _sourceOffset; + private int? _tokenStartOffset; private bool _isInQuotedSection; public QueryTokenizer(string source) { - ArgumentGuard.NotNull(source, nameof(source)); + ArgumentNullException.ThrowIfNull(source); _source = source; } @@ -36,11 +37,14 @@ public IEnumerable EnumerateTokens() { _textBuffer.Clear(); _isInQuotedSection = false; - _offset = 0; + _sourceOffset = 0; + _tokenStartOffset = null; - while (_offset < _source.Length) + while (_sourceOffset < _source.Length) { - char ch = _source[_offset]; + _tokenStartOffset ??= _sourceOffset; + + char ch = _source[_sourceOffset]; if (ch == '\'') { @@ -51,7 +55,7 @@ public IEnumerable EnumerateTokens() if (peeked == '\'') { _textBuffer.Append(ch); - _offset += 2; + _sourceOffset += 2; continue; } @@ -64,7 +68,7 @@ public IEnumerable EnumerateTokens() { if (_textBuffer.Length > 0) { - throw new QueryParseException("Unexpected ' outside text."); + throw new QueryParseException("Unexpected ' outside text.", _sourceOffset); } _isInQuotedSection = true; @@ -83,25 +87,27 @@ public IEnumerable EnumerateTokens() yield return identifierToken; } - yield return new Token(singleCharacterTokenKind.Value); + yield return new Token(singleCharacterTokenKind.Value, _sourceOffset); + + _tokenStartOffset = null; } else { - if (_textBuffer.Length == 0 && ch == ' ' && !_isInQuotedSection) + if (ch == ' ' && !_isInQuotedSection) { - throw new QueryParseException("Unexpected whitespace."); + throw new QueryParseException("Unexpected whitespace.", _sourceOffset); } _textBuffer.Append(ch); } } - _offset++; + _sourceOffset++; } if (_isInQuotedSection) { - throw new QueryParseException("' expected."); + throw new QueryParseException("' expected.", _sourceOffset - 1); } Token? lastToken = ProduceTokenFromTextBuffer(false); @@ -119,7 +125,7 @@ private bool IsMinusInsideText(TokenKind kind) private char? PeekChar() { - return _offset + 1 < _source.Length ? _source[_offset + 1] : null; + return _sourceOffset + 1 < _source.Length ? _source[_sourceOffset + 1] : null; } private static TokenKind? TryGetSingleCharacterTokenKind(char ch) @@ -131,9 +137,13 @@ private bool IsMinusInsideText(TokenKind kind) { if (isQuotedText || _textBuffer.Length > 0) { + int tokenStartOffset = _tokenStartOffset!.Value; string text = _textBuffer.ToString(); + _textBuffer.Clear(); - return new Token(isQuotedText ? TokenKind.QuotedText : TokenKind.Text, text); + _tokenStartOffset = null; + + return new Token(isQuotedText ? TokenKind.QuotedText : TokenKind.Text, text, tokenStartOffset); } return null; diff --git a/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs new file mode 100644 index 0000000000..e8df9b4a88 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/SortParser.cs @@ -0,0 +1,148 @@ +using System.Collections.Immutable; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings.FieldChains; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +[PublicAPI] +public class SortParser : QueryExpressionParser, ISortParser +{ + /// + public SortExpression Parse(string source, ResourceType resourceType) + { + ArgumentNullException.ThrowIfNull(resourceType); + + Tokenize(source); + + SortExpression expression = ParseSort(resourceType); + + AssertTokenStackIsEmpty(); + + return expression; + } + + protected virtual SortExpression ParseSort(ResourceType resourceType) + { + ArgumentNullException.ThrowIfNull(resourceType); + + SortElementExpression firstElement = ParseSortElement(resourceType); + + ImmutableArray.Builder elementsBuilder = ImmutableArray.CreateBuilder(); + elementsBuilder.Add(firstElement); + + while (TokenStack.Count > 0) + { + EatSingleCharacterToken(TokenKind.Comma); + + SortElementExpression nextElement = ParseSortElement(resourceType); + elementsBuilder.Add(nextElement); + } + + return new SortExpression(elementsBuilder.ToImmutable()); + } + + protected virtual SortElementExpression ParseSortElement(ResourceType resourceType) + { + ArgumentNullException.ThrowIfNull(resourceType); + + bool isAscending = true; + + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Minus) + { + TokenStack.Pop(); + isAscending = false; + } + + // An attribute or relationship name usually matches a single field, even when overridden in derived types. + // But in the following case, two attributes are matched on GET /shoppingBaskets?sort=bonusPoints: + // + // public abstract class ShoppingBasket : Identifiable + // { + // } + // + // public sealed class SilverShoppingBasket : ShoppingBasket + // { + // [Attr] + // public short BonusPoints { get; set; } + // } + // + // public sealed class PlatinumShoppingBasket : ShoppingBasket + // { + // [Attr] + // public long BonusPoints { get; set; } + // } + // + // In this case there are two distinct BonusPoints fields (with different data types). And the sort order depends + // on which attribute is used. + // + // Because there is no syntax to pick one, ParseFieldChain() fails with an error. We could add optional upcast syntax + // (which would be required in this case) in the future to make it work, if desired. + + QueryExpression target; + + if (TokenStack.TryPeek(out nextToken) && nextToken is { Kind: TokenKind.Text } && IsFunction(nextToken.Value!)) + { + target = ParseFunction(resourceType); + } + else + { + string errorMessage = !isAscending ? "Count function or field name expected." : "-, count function or field name expected."; + target = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.AllowDerivedTypes, resourceType, errorMessage); + } + + return new SortElementExpression(target, isAscending); + } + + protected virtual bool IsFunction(string name) + { + ArgumentException.ThrowIfNullOrEmpty(name); + + return name == Keywords.Count; + } + + protected virtual FunctionExpression ParseFunction(ResourceType resourceType) + { + ArgumentNullException.ThrowIfNull(resourceType); + + if (TokenStack.TryPeek(out Token? nextToken) && nextToken.Kind == TokenKind.Text) + { + switch (nextToken.Value) + { + case Keywords.Count: + { + return ParseCount(resourceType); + } + } + } + + int position = GetNextTokenPositionOrEnd(); + throw new QueryParseException("Count function expected.", position); + } + + private CountExpression ParseCount(ResourceType resourceType) + { + EatText(Keywords.Count); + EatSingleCharacterToken(TokenKind.OpenParen); + + ResourceFieldChainExpression targetCollection = + ParseFieldChain(BuiltInPatterns.ToOneChainEndingInToMany, FieldChainPatternMatchOptions.AllowDerivedTypes, resourceType, null); + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new CountExpression(targetCollection); + } + + protected override void ValidateField(ResourceFieldAttribute field, int position) + { + ArgumentNullException.ThrowIfNull(field); + + if (field is AttrAttribute attribute && !attribute.Capabilities.HasFlag(AttrCapabilities.AllowSort)) + { + throw new QueryParseException($"Sorting on attribute '{attribute.PublicName}' is not allowed.", position); + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldSetParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldSetParser.cs new file mode 100644 index 0000000000..9bf6a89daa --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldSetParser.cs @@ -0,0 +1,61 @@ +using System.Collections.Immutable; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings.FieldChains; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +[PublicAPI] +public class SparseFieldSetParser : QueryExpressionParser, ISparseFieldSetParser +{ + /// + public SparseFieldSetExpression? Parse(string source, ResourceType resourceType) + { + ArgumentNullException.ThrowIfNull(resourceType); + + Tokenize(source); + + SparseFieldSetExpression? expression = ParseSparseFieldSet(resourceType); + + AssertTokenStackIsEmpty(); + + return expression; + } + + protected virtual SparseFieldSetExpression? ParseSparseFieldSet(ResourceType resourceType) + { + ArgumentNullException.ThrowIfNull(resourceType); + + ImmutableHashSet.Builder fieldSetBuilder = ImmutableHashSet.CreateBuilder(); + + while (TokenStack.Count > 0) + { + if (fieldSetBuilder.Count > 0) + { + EatSingleCharacterToken(TokenKind.Comma); + } + + ResourceFieldChainExpression nextChain = + ParseFieldChain(BuiltInPatterns.SingleField, FieldChainPatternMatchOptions.None, resourceType, "Field name expected."); + + ResourceFieldAttribute nextField = nextChain.Fields.Single(); + fieldSetBuilder.Add(nextField); + } + + return fieldSetBuilder.Count > 0 ? new SparseFieldSetExpression(fieldSetBuilder.ToImmutable()) : null; + } + + protected override void ValidateField(ResourceFieldAttribute field, int position) + { + ArgumentNullException.ThrowIfNull(field); + + if (field.IsViewBlocked()) + { + string kind = field is AttrAttribute ? "attribute" : "relationship"; + throw new QueryParseException($"Retrieving the {kind} '{field.PublicName}' is not allowed.", position); + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldTypeParser.cs b/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldTypeParser.cs new file mode 100644 index 0000000000..00c5c6a525 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/SparseFieldTypeParser.cs @@ -0,0 +1,72 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; + +namespace JsonApiDotNetCore.Queries.Parsing; + +/// +[PublicAPI] +public class SparseFieldTypeParser : QueryExpressionParser, ISparseFieldTypeParser +{ + private readonly IResourceGraph _resourceGraph; + + public SparseFieldTypeParser(IResourceGraph resourceGraph) + { + ArgumentNullException.ThrowIfNull(resourceGraph); + + _resourceGraph = resourceGraph; + } + + /// + public ResourceType Parse(string source) + { + Tokenize(source); + + ResourceType resourceType = ParseSparseFieldType(); + + AssertTokenStackIsEmpty(); + + return resourceType; + } + + protected virtual ResourceType ParseSparseFieldType() + { + int position = GetNextTokenPositionOrEnd(); + + if (!TokenStack.TryPop(out Token? token) || token.Kind != TokenKind.Text) + { + throw new QueryParseException("Parameter name expected.", position); + } + + EatSingleCharacterToken(TokenKind.OpenBracket); + + ResourceType resourceType = ParseResourceType(); + + EatSingleCharacterToken(TokenKind.CloseBracket); + + return resourceType; + } + + private ResourceType ParseResourceType() + { + int position = GetNextTokenPositionOrEnd(); + + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.Text) + { + return GetResourceType(token.Value!, token.Position); + } + + throw new QueryParseException("Resource type expected.", position); + } + + private ResourceType GetResourceType(string publicName, int position) + { + ResourceType? resourceType = _resourceGraph.FindResourceType(publicName); + + if (resourceType == null) + { + throw new QueryParseException($"Resource type '{publicName}' does not exist.", position); + } + + return resourceType; + } +} diff --git a/src/JsonApiDotNetCore/Queries/Parsing/Token.cs b/src/JsonApiDotNetCore/Queries/Parsing/Token.cs new file mode 100644 index 0000000000..b4751288bf --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/Token.cs @@ -0,0 +1,22 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Queries.Parsing; + +[PublicAPI] +public class Token(TokenKind kind, int position) +{ + public TokenKind Kind { get; } = kind; + public string? Value { get; } + public int Position { get; } = position; + + public Token(TokenKind kind, string value, int position) + : this(kind, position) + { + Value = value; + } + + public override string ToString() + { + return Value == null ? $"{Kind} at {Position}" : $"{Kind}: '{Value}' at {Position}"; + } +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/TokenKind.cs b/src/JsonApiDotNetCore/Queries/Parsing/TokenKind.cs similarity index 75% rename from src/JsonApiDotNetCore/Queries/Internal/Parsing/TokenKind.cs rename to src/JsonApiDotNetCore/Queries/Parsing/TokenKind.cs index f73cbd3418..23fd428bf5 100644 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/TokenKind.cs +++ b/src/JsonApiDotNetCore/Queries/Parsing/TokenKind.cs @@ -1,4 +1,4 @@ -namespace JsonApiDotNetCore.Queries.Internal.Parsing; +namespace JsonApiDotNetCore.Queries.Parsing; public enum TokenKind { diff --git a/src/JsonApiDotNetCore/Queries/QueryLayer.cs b/src/JsonApiDotNetCore/Queries/QueryLayer.cs index c460560a33..49a9ee92a2 100644 --- a/src/JsonApiDotNetCore/Queries/QueryLayer.cs +++ b/src/JsonApiDotNetCore/Queries/QueryLayer.cs @@ -11,6 +11,8 @@ namespace JsonApiDotNetCore.Queries; [PublicAPI] public sealed class QueryLayer { + internal bool IsEmpty => Filter == null && Sort == null && Pagination?.PageSize == null && (Selection == null || Selection.IsEmpty); + public ResourceType ResourceType { get; } public IncludeExpression? Include { get; set; } @@ -21,7 +23,7 @@ public sealed class QueryLayer public QueryLayer(ResourceType resourceType) { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + ArgumentNullException.ThrowIfNull(resourceType); ResourceType = resourceType; } @@ -42,7 +44,7 @@ internal void WriteLayer(IndentingStringWriter writer, string? prefix) using (writer.Indent()) { - if (Include != null) + if (Include is { Elements.Count: > 0 }) { writer.WriteLine($"{nameof(Include)}: {Include}"); } diff --git a/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs new file mode 100644 index 0000000000..7954aa0e76 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryLayerComposer.cs @@ -0,0 +1,610 @@ +using System.Collections.Immutable; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Diagnostics; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries; + +/// +[PublicAPI] +public class QueryLayerComposer : IQueryLayerComposer +{ + private readonly IQueryConstraintProvider[] _constraintProviders; + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; + private readonly IJsonApiOptions _options; + private readonly IPaginationContext _paginationContext; + private readonly ITargetedFields _targetedFields; + private readonly IEvaluatedIncludeCache _evaluatedIncludeCache; + private readonly ISparseFieldSetCache _sparseFieldSetCache; + + public QueryLayerComposer(IEnumerable constraintProviders, IResourceDefinitionAccessor resourceDefinitionAccessor, + IJsonApiOptions options, IPaginationContext paginationContext, ITargetedFields targetedFields, IEvaluatedIncludeCache evaluatedIncludeCache, + ISparseFieldSetCache sparseFieldSetCache) + { + ArgumentNullException.ThrowIfNull(constraintProviders); + ArgumentNullException.ThrowIfNull(resourceDefinitionAccessor); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(paginationContext); + ArgumentNullException.ThrowIfNull(targetedFields); + ArgumentNullException.ThrowIfNull(evaluatedIncludeCache); + ArgumentNullException.ThrowIfNull(sparseFieldSetCache); + + _constraintProviders = constraintProviders as IQueryConstraintProvider[] ?? constraintProviders.ToArray(); + _resourceDefinitionAccessor = resourceDefinitionAccessor; + _options = options; + _paginationContext = paginationContext; + _targetedFields = targetedFields; + _evaluatedIncludeCache = evaluatedIncludeCache; + _sparseFieldSetCache = sparseFieldSetCache; + } + + /// + public FilterExpression? GetPrimaryFilterFromConstraints(ResourceType primaryResourceType) + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:wrap_before_first_method_call true + + ReadOnlyCollection filtersInTopScope = _constraintProviders + .SelectMany(provider => provider.GetConstraints()) + .Where(constraint => constraint.Scope == null) + .Select(constraint => constraint.Expression) + .OfType() + .ToArray() + .AsReadOnly(); + + // @formatter:wrap_before_first_method_call restore + // @formatter:wrap_chained_method_calls restore + + return GetFilter(filtersInTopScope, primaryResourceType); + } + + /// + public FilterExpression? GetSecondaryFilterFromConstraints([DisallowNull] TId primaryId, HasManyAttribute hasManyRelationship) + { + ArgumentNullException.ThrowIfNull(hasManyRelationship); + + if (hasManyRelationship.InverseNavigationProperty == null) + { + return null; + } + + RelationshipAttribute? inverseRelationship = + hasManyRelationship.RightType.FindRelationshipByPropertyName(hasManyRelationship.InverseNavigationProperty.Name); + + if (inverseRelationship == null) + { + return null; + } + + // @formatter:wrap_chained_method_calls chop_always + // @formatter:wrap_before_first_method_call true + + ReadOnlyCollection filtersInSecondaryScope = _constraintProviders + .SelectMany(provider => provider.GetConstraints()) + .Where(constraint => constraint.Scope == null) + .Select(constraint => constraint.Expression) + .OfType() + .ToArray() + .AsReadOnly(); + + // @formatter:wrap_before_first_method_call restore + // @formatter:wrap_chained_method_calls restore + + FilterExpression? primaryFilter = GetFilter(Array.Empty(), hasManyRelationship.LeftType); + + if (primaryFilter != null && inverseRelationship is HasOneAttribute) + { + // We can't lift the field chains in a primary filter, because there's no way for a custom filter expression to express + // the scope of its chains. See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1671. + return null; + } + + FilterExpression? secondaryFilter = GetFilter(filtersInSecondaryScope, hasManyRelationship.RightType); + FilterExpression inverseFilter = GetInverseRelationshipFilter(primaryId, primaryFilter, hasManyRelationship, inverseRelationship); + + return LogicalExpression.Compose(LogicalOperator.And, inverseFilter, secondaryFilter); + } + + private static FilterExpression GetInverseRelationshipFilter([DisallowNull] TId primaryId, FilterExpression? primaryFilter, + HasManyAttribute relationship, RelationshipAttribute inverseRelationship) + { + return inverseRelationship is HasManyAttribute hasManyInverseRelationship + ? GetInverseHasManyRelationshipFilter(primaryId, primaryFilter, relationship, hasManyInverseRelationship) + : GetInverseHasOneRelationshipFilter(primaryId, relationship, (HasOneAttribute)inverseRelationship); + } + + private static ComparisonExpression GetInverseHasOneRelationshipFilter([DisallowNull] TId primaryId, HasManyAttribute relationship, + HasOneAttribute inverseRelationship) + { + AttrAttribute idAttribute = GetIdAttribute(relationship.LeftType); + var idChain = new ResourceFieldChainExpression(ImmutableArray.Create(inverseRelationship, idAttribute)); + + return new ComparisonExpression(ComparisonOperator.Equals, idChain, new LiteralConstantExpression(primaryId)); + } + + private static HasExpression GetInverseHasManyRelationshipFilter([DisallowNull] TId primaryId, FilterExpression? primaryFilter, + HasManyAttribute relationship, HasManyAttribute inverseRelationship) + { + AttrAttribute idAttribute = GetIdAttribute(relationship.LeftType); + var idChain = new ResourceFieldChainExpression(ImmutableArray.Create(idAttribute)); + var idComparison = new ComparisonExpression(ComparisonOperator.Equals, idChain, new LiteralConstantExpression(primaryId)); + + FilterExpression filter = LogicalExpression.Compose(LogicalOperator.And, idComparison, primaryFilter)!; + return new HasExpression(new ResourceFieldChainExpression(inverseRelationship), filter); + } + + /// + public QueryLayer ComposeFromConstraints(ResourceType requestResourceType) + { + ArgumentNullException.ThrowIfNull(requestResourceType); + + ImmutableArray constraints = [.. _constraintProviders.SelectMany(provider => provider.GetConstraints())]; + + QueryLayer topLayer = ComposeTopLayer(constraints, requestResourceType); + topLayer.Include = ComposeChildren(topLayer, constraints); + + _evaluatedIncludeCache.Set(topLayer.Include); + + return topLayer; + } + + private QueryLayer ComposeTopLayer(ImmutableArray constraints, ResourceType resourceType) + { + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Top-level query composition"); + + // @formatter:wrap_chained_method_calls chop_always + // @formatter:wrap_before_first_method_call true + + ReadOnlyCollection expressionsInTopScope = constraints + .Where(constraint => constraint.Scope == null) + .Select(constraint => constraint.Expression) + .ToArray() + .AsReadOnly(); + + // @formatter:wrap_before_first_method_call restore + // @formatter:wrap_chained_method_calls restore + + PaginationExpression topPagination = GetPagination(expressionsInTopScope, resourceType); + _paginationContext.PageSize = topPagination.PageSize; + _paginationContext.PageNumber = topPagination.PageNumber; + + var topLayer = new QueryLayer(resourceType) + { + Filter = GetFilter(expressionsInTopScope, resourceType), + Sort = GetSort(expressionsInTopScope, resourceType), + Pagination = topPagination, + Selection = GetSelectionForSparseAttributeSet(resourceType) + }; + + if (topLayer is { Pagination.PageSize: not null, Sort: null }) + { + topLayer.Sort = CreateSortById(resourceType); + } + + return topLayer; + } + + private IncludeExpression ComposeChildren(QueryLayer topLayer, ImmutableArray constraints) + { + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Nested query composition"); + + // @formatter:wrap_chained_method_calls chop_always + // @formatter:wrap_before_first_method_call true + + IncludeExpression include = constraints + .Where(constraint => constraint.Scope == null) + .Select(constraint => constraint.Expression) + .OfType() + .FirstOrDefault() ?? IncludeExpression.Empty; + + // @formatter:wrap_before_first_method_call restore + // @formatter:wrap_chained_method_calls restore + + IImmutableSet includeElements = + ProcessIncludeSet(include.Elements, topLayer, ImmutableArray.Empty, constraints); + + return !ReferenceEquals(includeElements, include.Elements) + ? includeElements.Count > 0 ? new IncludeExpression(includeElements) : IncludeExpression.Empty + : include; + } + + private IImmutableSet ProcessIncludeSet(IImmutableSet includeElements, QueryLayer parentLayer, + ImmutableArray parentRelationshipChain, ImmutableArray constraints) + { + IImmutableSet includeElementsEvaluated = GetIncludeElements(includeElements, parentLayer.ResourceType); + + var updatesInChildren = new Dictionary>(); + + foreach (IncludeElementExpression includeElement in includeElementsEvaluated) + { + parentLayer.Selection ??= new FieldSelection(); + FieldSelectors selectors = parentLayer.Selection.GetOrCreateSelectors(includeElement.Relationship.LeftType); + + if (!selectors.ContainsField(includeElement.Relationship)) + { + ImmutableArray relationshipChain = parentRelationshipChain.Add(includeElement.Relationship); + + // @formatter:wrap_chained_method_calls chop_always + // @formatter:wrap_before_first_method_call true + + ReadOnlyCollection expressionsInCurrentScope = constraints + .Where(constraint => constraint.Scope != null && constraint.Scope.Fields.SequenceEqual(relationshipChain)) + .Select(constraint => constraint.Expression) + .ToArray() + .AsReadOnly(); + + // @formatter:wrap_before_first_method_call restore + // @formatter:wrap_chained_method_calls restore + + ResourceType resourceType = includeElement.Relationship.RightType; + bool isToManyRelationship = includeElement.Relationship is HasManyAttribute; + + var subLayer = new QueryLayer(resourceType) + { + Filter = isToManyRelationship ? GetFilter(expressionsInCurrentScope, resourceType) : null, + Sort = isToManyRelationship ? GetSort(expressionsInCurrentScope, resourceType) : null, + Pagination = isToManyRelationship ? GetPagination(expressionsInCurrentScope, resourceType) : null, + Selection = GetSelectionForSparseAttributeSet(resourceType) + }; + + if (subLayer is { Pagination.PageSize: not null, Sort: null }) + { + subLayer.Sort = CreateSortById(resourceType); + } + + selectors.IncludeRelationship(includeElement.Relationship, subLayer); + + IImmutableSet updatedChildren = ProcessIncludeSet(includeElement.Children, subLayer, relationshipChain, constraints); + + if (!ReferenceEquals(includeElement.Children, updatedChildren)) + { + updatesInChildren.Add(includeElement, updatedChildren); + } + } + } + + EliminateRedundantSelectors(parentLayer); + + return updatesInChildren.Count == 0 ? includeElementsEvaluated : ApplyIncludeElementUpdates(includeElementsEvaluated, updatesInChildren); + } + + private static void EliminateRedundantSelectors(QueryLayer parentLayer) + { + if (parentLayer.Selection != null) + { + foreach ((ResourceType resourceType, FieldSelectors selectors) in parentLayer.Selection.ToArray()) + { + if (selectors.ContainsOnlyRelationships && selectors.Values.OfType().All(subLayer => subLayer.IsEmpty)) + { + parentLayer.Selection.Remove(resourceType); + } + } + + if (parentLayer.Selection.IsEmpty) + { + parentLayer.Selection = null; + } + } + } + + private static ImmutableHashSet ApplyIncludeElementUpdates(IImmutableSet includeElements, + Dictionary> updatesInChildren) + { + ImmutableHashSet.Builder newElementsBuilder = ImmutableHashSet.CreateBuilder(); + newElementsBuilder.UnionWith(includeElements); + + foreach ((IncludeElementExpression existingElement, IImmutableSet updatedChildren) in updatesInChildren) + { + newElementsBuilder.Remove(existingElement); + newElementsBuilder.Add(new IncludeElementExpression(existingElement.Relationship, updatedChildren)); + } + + return newElementsBuilder.ToImmutable(); + } + + /// + public QueryLayer ComposeForGetById([DisallowNull] TId id, ResourceType primaryResourceType, TopFieldSelection fieldSelection) + { + ArgumentNullException.ThrowIfNull(primaryResourceType); + + AttrAttribute idAttribute = GetIdAttribute(primaryResourceType); + + QueryLayer queryLayer = ComposeFromConstraints(primaryResourceType); + queryLayer.Sort = null; + queryLayer.Pagination = null; + queryLayer.Filter = CreateFilterByIds([id], idAttribute, queryLayer.Filter); + + if (fieldSelection == TopFieldSelection.OnlyIdAttribute) + { + queryLayer.Selection = new FieldSelection(); + FieldSelectors selectors = queryLayer.Selection.GetOrCreateSelectors(primaryResourceType); + selectors.IncludeAttribute(idAttribute); + } + else if (fieldSelection == TopFieldSelection.WithAllAttributes && queryLayer.Selection != null) + { + // Discard any top-level ?fields[]= or attribute exclusions from resource definition, because we need the full database row. + FieldSelectors selectors = queryLayer.Selection.GetOrCreateSelectors(primaryResourceType); + selectors.RemoveAttributes(); + } + + return queryLayer; + } + + /// + public QueryLayer ComposeSecondaryLayerForRelationship(ResourceType secondaryResourceType) + { + ArgumentNullException.ThrowIfNull(secondaryResourceType); + + QueryLayer secondaryLayer = ComposeFromConstraints(secondaryResourceType); + secondaryLayer.Selection = GetSelectionForRelationship(secondaryResourceType); + secondaryLayer.Include = null; + + return secondaryLayer; + } + + private FieldSelection GetSelectionForRelationship(ResourceType secondaryResourceType) + { + var selection = new FieldSelection(); + FieldSelectors selectors = selection.GetOrCreateSelectors(secondaryResourceType); + + IImmutableSet secondaryAttributeSet = _sparseFieldSetCache.GetIdAttributeSetForRelationshipQuery(secondaryResourceType); + selectors.IncludeAttributes(secondaryAttributeSet); + + return selection; + } + + /// + public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, ResourceType primaryResourceType, [DisallowNull] TId primaryId, + RelationshipAttribute relationship) + { + ArgumentNullException.ThrowIfNull(secondaryLayer); + ArgumentNullException.ThrowIfNull(primaryResourceType); + ArgumentNullException.ThrowIfNull(relationship); + + IncludeExpression? innerInclude = secondaryLayer.Include; + secondaryLayer.Include = null; + + if (relationship is HasOneAttribute) + { + secondaryLayer.Sort = null; + } + + var primarySelection = new FieldSelection(); + FieldSelectors primarySelectors = primarySelection.GetOrCreateSelectors(primaryResourceType); + + IImmutableSet primaryAttributeSet = _sparseFieldSetCache.GetIdAttributeSetForRelationshipQuery(primaryResourceType); + primarySelectors.IncludeAttributes(primaryAttributeSet); + primarySelectors.IncludeRelationship(relationship, secondaryLayer); + + FilterExpression? primaryFilter = GetFilter(Array.Empty(), primaryResourceType); + AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResourceType); + + return new QueryLayer(primaryResourceType) + { + Include = RewriteIncludeForSecondaryEndpoint(innerInclude, relationship), + Filter = CreateFilterByIds([primaryId], primaryIdAttribute, primaryFilter), + Selection = primarySelection + }; + } + + private IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression? relativeInclude, RelationshipAttribute secondaryRelationship) + { + IncludeElementExpression parentElement = relativeInclude != null + ? new IncludeElementExpression(secondaryRelationship, relativeInclude.Elements) + : new IncludeElementExpression(secondaryRelationship); + + return new IncludeExpression(ImmutableHashSet.Create(parentElement)); + } + + private FilterExpression? CreateFilterByIds(TId[] ids, AttrAttribute idAttribute, FilterExpression? existingFilter) + { + var idChain = new ResourceFieldChainExpression(idAttribute); + + FilterExpression? filter = null; + + if (ids.Length == 1) + { + var constant = new LiteralConstantExpression(ids.Single()!); + filter = new ComparisonExpression(ComparisonOperator.Equals, idChain, constant); + } + else if (ids.Length > 1) + { + ImmutableHashSet constants = ids.Select(id => new LiteralConstantExpression(id!)).ToImmutableHashSet(); + filter = new AnyExpression(idChain, constants); + } + + return LogicalExpression.Compose(LogicalOperator.And, filter, existingFilter); + } + + /// + public QueryLayer ComposeForUpdate([DisallowNull] TId id, ResourceType primaryResourceType) + { + ArgumentNullException.ThrowIfNull(primaryResourceType); + + ImmutableHashSet includeElements = _targetedFields.Relationships + .Select(relationship => new IncludeElementExpression(relationship)).ToImmutableHashSet(); + + AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResourceType); + + QueryLayer primaryLayer = ComposeTopLayer(ImmutableArray.Empty, primaryResourceType); + primaryLayer.Include = includeElements.Count > 0 ? new IncludeExpression(includeElements) : IncludeExpression.Empty; + primaryLayer.Sort = null; + primaryLayer.Pagination = null; + primaryLayer.Filter = CreateFilterByIds([id], primaryIdAttribute, primaryLayer.Filter); + primaryLayer.Selection = null; + + return primaryLayer; + } + + /// + public IEnumerable<(QueryLayer, RelationshipAttribute)> ComposeForGetTargetedSecondaryResourceIds(IIdentifiable primaryResource) + { + ArgumentNullException.ThrowIfNull(primaryResource); + + foreach (RelationshipAttribute relationship in _targetedFields.Relationships) + { + object? rightValue = relationship.GetValue(primaryResource); + HashSet rightResourceIds = CollectionConverter.Instance.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); + + if (rightResourceIds.Count > 0) + { + QueryLayer queryLayer = ComposeForGetRelationshipRightIds(relationship, rightResourceIds); + yield return (queryLayer, relationship); + } + } + } + + /// + public QueryLayer ComposeForGetRelationshipRightIds(RelationshipAttribute relationship, ICollection rightResourceIds) + { + ArgumentNullException.ThrowIfNull(relationship); + ArgumentNullException.ThrowIfNull(rightResourceIds); + + AttrAttribute rightIdAttribute = GetIdAttribute(relationship.RightType); + + object[] typedIds = rightResourceIds.Select(resource => resource.GetTypedId()).ToArray(); + + FilterExpression? baseFilter = GetFilter(Array.Empty(), relationship.RightType); + FilterExpression? filter = CreateFilterByIds(typedIds, rightIdAttribute, baseFilter); + + var selection = new FieldSelection(); + FieldSelectors selectors = selection.GetOrCreateSelectors(relationship.RightType); + selectors.IncludeAttribute(rightIdAttribute); + + return new QueryLayer(relationship.RightType) + { + Include = IncludeExpression.Empty, + Filter = filter, + Selection = selection + }; + } + + /// + public QueryLayer ComposeForHasMany(HasManyAttribute hasManyRelationship, [DisallowNull] TId leftId, ICollection rightResourceIds) + { + ArgumentNullException.ThrowIfNull(hasManyRelationship); + ArgumentNullException.ThrowIfNull(rightResourceIds); + + AttrAttribute leftIdAttribute = GetIdAttribute(hasManyRelationship.LeftType); + AttrAttribute rightIdAttribute = GetIdAttribute(hasManyRelationship.RightType); + object[] rightTypedIds = rightResourceIds.Select(resource => resource.GetTypedId()).ToArray(); + + FilterExpression? leftFilter = CreateFilterByIds([leftId], leftIdAttribute, null); + FilterExpression? rightFilter = CreateFilterByIds(rightTypedIds, rightIdAttribute, null); + + var secondarySelection = new FieldSelection(); + FieldSelectors secondarySelectors = secondarySelection.GetOrCreateSelectors(hasManyRelationship.RightType); + secondarySelectors.IncludeAttribute(rightIdAttribute); + + QueryLayer secondaryLayer = new(hasManyRelationship.RightType) + { + Filter = rightFilter, + Selection = secondarySelection + }; + + var primarySelection = new FieldSelection(); + FieldSelectors primarySelectors = primarySelection.GetOrCreateSelectors(hasManyRelationship.LeftType); + primarySelectors.IncludeRelationship(hasManyRelationship, secondaryLayer); + primarySelectors.IncludeAttribute(leftIdAttribute); + + return new QueryLayer(hasManyRelationship.LeftType) + { + Include = new IncludeExpression(ImmutableHashSet.Create(new IncludeElementExpression(hasManyRelationship))), + Filter = leftFilter, + Selection = primarySelection + }; + } + + protected virtual IImmutableSet GetIncludeElements(IImmutableSet includeElements, + ResourceType resourceType) + { + ArgumentNullException.ThrowIfNull(includeElements); + ArgumentNullException.ThrowIfNull(resourceType); + + return _resourceDefinitionAccessor.OnApplyIncludes(resourceType, includeElements); + } + + protected virtual FilterExpression? GetFilter(IReadOnlyCollection expressionsInScope, ResourceType resourceType) + { + ArgumentNullException.ThrowIfNull(expressionsInScope); + ArgumentNullException.ThrowIfNull(resourceType); + + FilterExpression[] filters = expressionsInScope.OfType().ToArray(); + FilterExpression? filter = LogicalExpression.Compose(LogicalOperator.And, filters); + + return _resourceDefinitionAccessor.OnApplyFilter(resourceType, filter); + } + + protected virtual SortExpression? GetSort(IReadOnlyCollection expressionsInScope, ResourceType resourceType) + { + ArgumentNullException.ThrowIfNull(expressionsInScope); + ArgumentNullException.ThrowIfNull(resourceType); + + SortExpression? sort = expressionsInScope.OfType().FirstOrDefault(); + + return _resourceDefinitionAccessor.OnApplySort(resourceType, sort); + } + + private SortExpression CreateSortById(ResourceType resourceType) + { + AttrAttribute idAttribute = GetIdAttribute(resourceType); + var idAscendingSort = new SortElementExpression(new ResourceFieldChainExpression(idAttribute), true); + return new SortExpression(ImmutableArray.Create(idAscendingSort)); + } + + protected virtual PaginationExpression GetPagination(IReadOnlyCollection expressionsInScope, ResourceType resourceType) + { + ArgumentNullException.ThrowIfNull(expressionsInScope); + ArgumentNullException.ThrowIfNull(resourceType); + + PaginationExpression? pagination = expressionsInScope.OfType().FirstOrDefault(); + + pagination = _resourceDefinitionAccessor.OnApplyPagination(resourceType, pagination); + + pagination ??= new PaginationExpression(PageNumber.ValueOne, _options.DefaultPageSize); + + return pagination; + } + +#pragma warning disable AV1130 // Return type in method signature should be an interface to an unchangeable collection + protected virtual FieldSelection? GetSelectionForSparseAttributeSet(ResourceType resourceType) +#pragma warning restore AV1130 // Return type in method signature should be an interface to an unchangeable collection + { + ArgumentNullException.ThrowIfNull(resourceType); + + var selection = new FieldSelection(); + + HashSet resourceTypes = resourceType.GetAllConcreteDerivedTypes().ToHashSet(); + resourceTypes.Add(resourceType); + + foreach (ResourceType nextType in resourceTypes) + { + IImmutableSet fieldSet = _sparseFieldSetCache.GetSparseFieldSetForQuery(nextType); + + if (fieldSet.Count == 0) + { + continue; + } + + HashSet attributeSet = fieldSet.OfType().ToHashSet(); + + FieldSelectors selectors = selection.GetOrCreateSelectors(nextType); + selectors.IncludeAttributes(attributeSet); + + AttrAttribute idAttribute = GetIdAttribute(nextType); + selectors.IncludeAttribute(idAttribute); + } + + return selection.IsEmpty ? null : selection; + } + + private static AttrAttribute GetIdAttribute(ResourceType resourceType) + { + return resourceType.GetAttributeByPropertyName(nameof(Identifiable.Id)); + } +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/ExpressionTreeFormatter.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/ExpressionTreeFormatter.cs new file mode 100644 index 0000000000..c6b1bc4bb3 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/ExpressionTreeFormatter.cs @@ -0,0 +1,53 @@ +using System.Linq.Expressions; +using System.Reflection; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// Converts a to readable text, if the AgileObjects.ReadableExpressions NuGet package is referenced. +/// +internal sealed class ExpressionTreeFormatter +{ + private static readonly Lazy LazyToReadableStringMethod = new(GetToReadableStringMethod, LazyThreadSafetyMode.ExecutionAndPublication); + + public static ExpressionTreeFormatter Instance { get; } = new(); + + private ExpressionTreeFormatter() + { + } + + private static MethodInvoker? GetToReadableStringMethod() + { + Assembly? assembly = TryLoadAssembly(); + Type? type = assembly?.GetType("AgileObjects.ReadableExpressions.ExpressionExtensions", false); + MethodInfo? method = type?.GetMethods(BindingFlags.Public | BindingFlags.Static).FirstOrDefault(method => method.Name == "ToReadableString"); + return method != null ? MethodInvoker.Create(method) : null; + } + + private static Assembly? TryLoadAssembly() + { + try + { + return Assembly.Load("AgileObjects.ReadableExpressions"); + } + catch (Exception exception) when (exception is ArgumentException or IOException or BadImageFormatException) + { + } + + return null; + } + + public string? GetText(Expression expression) + { + ArgumentNullException.ThrowIfNull(expression); + + try + { + return LazyToReadableStringMethod.Value?.Invoke(null, expression, null) as string; + } + catch (Exception exception) when (exception is TargetException or InvalidOperationException or TargetParameterCountException or NotSupportedException) + { + return null; + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/IIncludeClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IIncludeClauseBuilder.cs new file mode 100644 index 0000000000..0182552b8e --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IIncludeClauseBuilder.cs @@ -0,0 +1,19 @@ +using System.Linq.Expressions; +using JsonApiDotNetCore.Queries.Expressions; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// Transforms into calls. +/// +/// +/// Types that implement this interface are stateless by design. Existing instances are reused recursively (perhaps this one not today, but that may +/// change), so don't store mutable state in private fields when implementing this interface or deriving from the built-in implementations. To pass +/// custom state, use the property. The only private field allowed is a stack where you push/pop state, so +/// it works recursively. +/// +public interface IIncludeClauseBuilder +{ + Expression ApplyInclude(IncludeExpression include, QueryClauseBuilderContext context); +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/IOrderClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IOrderClauseBuilder.cs new file mode 100644 index 0000000000..fdbd55d095 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IOrderClauseBuilder.cs @@ -0,0 +1,19 @@ +using System.Linq.Expressions; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// Transforms into +/// calls. +/// +/// +/// Types that implement this interface are stateless by design. Existing instances are reused recursively (perhaps this one not today, but that may +/// change), so don't store mutable state in private fields when implementing this interface or deriving from the built-in implementations. To pass +/// custom state, use the property. The only private field allowed is a stack where you push/pop state, so +/// it works recursively. +/// +public interface IOrderClauseBuilder +{ + Expression ApplyOrderBy(SortExpression expression, QueryClauseBuilderContext context); +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/IQueryableBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IQueryableBuilder.cs new file mode 100644 index 0000000000..7bf7b6b2f7 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IQueryableBuilder.cs @@ -0,0 +1,17 @@ +using System.Linq.Expressions; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// Drives conversion from into system trees. +/// +/// +/// Types that implement this interface are stateless by design. Existing instances are reused recursively (perhaps this one not today, but that may +/// change), so don't store mutable state in private fields when implementing this interface or deriving from the built-in implementations. To pass +/// custom state, use the property. The only private field allowed is a stack where you push/pop state, so +/// it works recursively. +/// +public interface IQueryableBuilder +{ + Expression ApplyQuery(QueryLayer layer, QueryableBuilderContext context); +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/ISelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/ISelectClauseBuilder.cs new file mode 100644 index 0000000000..25e79c4202 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/ISelectClauseBuilder.cs @@ -0,0 +1,19 @@ +using System.Linq.Expressions; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// Transforms into +/// calls. +/// +/// +/// Types that implement this interface are stateless by design. Existing instances are reused recursively (perhaps this one not today, but that may +/// change), so don't store mutable state in private fields when implementing this interface or deriving from the built-in implementations. To pass +/// custom state, use the property. The only private field allowed is a stack where you push/pop state, so +/// it works recursively. +/// +public interface ISelectClauseBuilder +{ + Expression ApplySelect(FieldSelection selection, QueryClauseBuilderContext context); +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/ISkipTakeClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/ISkipTakeClauseBuilder.cs new file mode 100644 index 0000000000..4016532a09 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/ISkipTakeClauseBuilder.cs @@ -0,0 +1,19 @@ +using System.Linq.Expressions; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// Transforms into and +/// calls. +/// +/// +/// Types that implement this interface are stateless by design. Existing instances are reused recursively (perhaps this one not today, but that may +/// change), so don't store mutable state in private fields when implementing this interface or deriving from the built-in implementations. To pass +/// custom state, use the property. The only private field allowed is a stack where you push/pop state, so +/// it works recursively. +/// +public interface ISkipTakeClauseBuilder +{ + Expression ApplySkipTake(PaginationExpression expression, QueryClauseBuilderContext context); +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/IWhereClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IWhereClauseBuilder.cs new file mode 100644 index 0000000000..f9e47cc714 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IWhereClauseBuilder.cs @@ -0,0 +1,19 @@ +using System.Linq.Expressions; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// Transforms into +/// calls. +/// +/// +/// Types that implement this interface are stateless by design. Existing instances are reused recursively (perhaps this one not today, but that may +/// change), so don't store mutable state in private fields when implementing this interface or deriving from the built-in implementations. To pass +/// custom state, use the property. The only private field allowed is a stack where you push/pop state, so +/// it works recursively. +/// +public interface IWhereClauseBuilder +{ + Expression ApplyWhere(FilterExpression filter, QueryClauseBuilderContext context); +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/IncludeClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IncludeClauseBuilder.cs new file mode 100644 index 0000000000..115202b138 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/IncludeClauseBuilder.cs @@ -0,0 +1,80 @@ +using System.Linq.Expressions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +[PublicAPI] +public class IncludeClauseBuilder : QueryClauseBuilder, IIncludeClauseBuilder +{ + private static readonly IncludeChainConverter IncludeChainConverter = new(); + + public virtual Expression ApplyInclude(IncludeExpression include, QueryClauseBuilderContext context) + { + ArgumentNullException.ThrowIfNull(include); + + return Visit(include, context); + } + + public override Expression VisitInclude(IncludeExpression expression, QueryClauseBuilderContext context) + { + // De-duplicate chains coming from derived relationships. + HashSet propertyPaths = []; + + ApplyEagerLoads(context.ResourceType.EagerLoads, null, propertyPaths); + + foreach (ResourceFieldChainExpression chain in IncludeChainConverter.GetRelationshipChains(expression)) + { + ProcessRelationshipChain(chain, propertyPaths); + } + + return ToExpression(context.Source, context.LambdaScope.Parameter.Type, propertyPaths); + } + + private static void ProcessRelationshipChain(ResourceFieldChainExpression chain, HashSet outputPropertyPaths) + { + string? path = null; + + foreach (RelationshipAttribute relationship in chain.Fields.Cast()) + { + path = path == null ? relationship.Property.Name : $"{path}.{relationship.Property.Name}"; + + ApplyEagerLoads(relationship.RightType.EagerLoads, path, outputPropertyPaths); + } + + outputPropertyPaths.Add(path!); + } + + private static void ApplyEagerLoads(IEnumerable eagerLoads, string? pathPrefix, ISet outputPropertyPaths) + { + foreach (EagerLoadAttribute eagerLoad in eagerLoads) + { + string path = pathPrefix != null ? $"{pathPrefix}.{eagerLoad.Property.Name}" : eagerLoad.Property.Name; + outputPropertyPaths.Add(path); + + ApplyEagerLoads(eagerLoad.Children, path, outputPropertyPaths); + } + } + + private static Expression ToExpression(Expression source, Type entityType, HashSet propertyPaths) + { + Expression expression = source; + + foreach (string propertyPath in propertyPaths) + { + expression = IncludeExtensionMethodCall(expression, entityType, propertyPath); + } + + return expression; + } + + private static MethodCallExpression IncludeExtensionMethodCall(Expression source, Type entityType, string navigationPropertyPath) + { + Expression navigationExpression = Expression.Constant(navigationPropertyPath); + + return Expression.Call(typeof(EntityFrameworkQueryableExtensions), "Include", [entityType], source, navigationExpression); + } +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/LambdaScope.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/LambdaScope.cs new file mode 100644 index 0000000000..6e94e459b5 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/LambdaScope.cs @@ -0,0 +1,54 @@ +using System.Linq.Expressions; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// A scoped lambda expression with a unique name. Disposing the instance releases the claimed name, so it can be reused. +/// +[PublicAPI] +public sealed class LambdaScope : IDisposable +{ + private readonly LambdaScopeFactory _owner; + + /// + /// Gets the lambda parameter. For example, 'person' in: person => person.Account.Name == "Joe". + /// + public ParameterExpression Parameter { get; } + + /// + /// Gets the lambda accessor. For example, 'person.Account' in: person => person.Account.Name == "Joe". + /// + public Expression Accessor { get; } + + private LambdaScope(LambdaScopeFactory owner, ParameterExpression parameter, Expression accessor) + { + _owner = owner; + Parameter = parameter; + Accessor = accessor; + } + + internal static LambdaScope Create(LambdaScopeFactory owner, Type elementType, string parameterName, Expression? accessorExpression = null) + { + ArgumentNullException.ThrowIfNull(owner); + ArgumentNullException.ThrowIfNull(elementType); + ArgumentException.ThrowIfNullOrEmpty(parameterName); + + ParameterExpression parameter = Expression.Parameter(elementType, parameterName); + Expression accessor = accessorExpression ?? parameter; + + return new LambdaScope(owner, parameter, accessor); + } + + public LambdaScope WithAccessor(Expression accessorExpression) + { + ArgumentNullException.ThrowIfNull(accessorExpression); + + return new LambdaScope(_owner, Parameter, accessorExpression); + } + + public void Dispose() + { + _owner.Release(this); + } +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/LambdaScopeFactory.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/LambdaScopeFactory.cs new file mode 100644 index 0000000000..3d9e87dc01 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/LambdaScopeFactory.cs @@ -0,0 +1,55 @@ +using System.Linq.Expressions; +using Humanizer; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// Produces lambda parameters with unique names. +/// +[PublicAPI] +public sealed class LambdaScopeFactory +{ + private readonly HashSet _namesInScope = []; + + /// + /// Finds the next unique lambda parameter name. Dispose the returned scope to release the claimed name, so it can be reused. + /// + public LambdaScope CreateScope(Type elementType, Expression? accessorExpression = null) + { + ArgumentNullException.ThrowIfNull(elementType); + + string parameterName = elementType.Name.Camelize(); + parameterName = EnsureUniqueName(parameterName); + _namesInScope.Add(parameterName); + + return LambdaScope.Create(this, elementType, parameterName, accessorExpression); + } + + private string EnsureUniqueName(string name) + { + if (!_namesInScope.Contains(name)) + { + return name; + } + + int counter = 1; + string alternativeName; + + do + { + counter++; + alternativeName = name + counter; + } + while (_namesInScope.Contains(alternativeName)); + + return alternativeName; + } + + internal void Release(LambdaScope lambdaScope) + { + ArgumentNullException.ThrowIfNull(lambdaScope); + + _namesInScope.Remove(lambdaScope.Parameter.Name!); + } +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/OrderClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/OrderClauseBuilder.cs new file mode 100644 index 0000000000..6528d63ff8 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/OrderClauseBuilder.cs @@ -0,0 +1,68 @@ +using System.Linq.Expressions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +[PublicAPI] +public class OrderClauseBuilder : QueryClauseBuilder, IOrderClauseBuilder +{ + public virtual Expression ApplyOrderBy(SortExpression expression, QueryClauseBuilderContext context) + { + ArgumentNullException.ThrowIfNull(expression); + + return Visit(expression, context); + } + + public override Expression VisitSort(SortExpression expression, QueryClauseBuilderContext context) + { + QueryClauseBuilderContext nextContext = context; + + foreach (SortElementExpression sortElement in expression.Elements) + { + Expression sortExpression = Visit(sortElement, nextContext); + nextContext = nextContext.WithSource(sortExpression); + } + + return nextContext.Source; + } + + public override Expression VisitSortElement(SortElementExpression expression, QueryClauseBuilderContext context) + { + Expression body = Visit(expression.Target, context); + LambdaExpression lambda = Expression.Lambda(body, context.LambdaScope.Parameter); + string operationName = GetOperationName(expression.IsAscending, context); + + return ExtensionMethodCall(context.Source, operationName, body.Type, lambda, context); + } + + private static string GetOperationName(bool isAscending, QueryClauseBuilderContext context) + { + bool hasPrecedingSort = false; + + if (context.Source is MethodCallExpression methodCall) + { + hasPrecedingSort = methodCall.Method.Name is "OrderBy" or "OrderByDescending" or "ThenBy" or "ThenByDescending"; + } + + if (hasPrecedingSort) + { + return isAscending ? "ThenBy" : "ThenByDescending"; + } + + return isAscending ? "OrderBy" : "OrderByDescending"; + } + + private static MethodCallExpression ExtensionMethodCall(Expression source, string operationName, Type keyType, LambdaExpression keySelector, + QueryClauseBuilderContext context) + { + Type[] typeArguments = + [ + context.LambdaScope.Parameter.Type, + keyType + ]; + + return Expression.Call(context.ExtensionType, operationName, typeArguments, source, keySelector); + } +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilder.cs new file mode 100644 index 0000000000..b34e5b56cd --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilder.cs @@ -0,0 +1,83 @@ +using System.Linq.Expressions; +using System.Reflection; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// Base class for transforming trees into system trees. +/// +public abstract class QueryClauseBuilder : QueryExpressionVisitor +{ + public override Expression DefaultVisit(QueryExpression expression, QueryClauseBuilderContext argument) + { + throw new NotSupportedException($"Unknown expression of type '{expression.GetType()}'."); + } + + public override Expression VisitCount(CountExpression expression, QueryClauseBuilderContext context) + { + Expression collectionExpression = Visit(expression.TargetCollection, context); + + MemberExpression? propertyExpression = GetCollectionCount(collectionExpression); + + if (propertyExpression == null) + { + throw new InvalidOperationException($"Field '{expression.TargetCollection}' must be a collection."); + } + + return propertyExpression; + } + + private static MemberExpression? GetCollectionCount(Expression? collectionExpression) + { + if (collectionExpression != null) + { + var properties = new HashSet(collectionExpression.Type.GetProperties()); + + if (collectionExpression.Type.IsInterface) + { + foreach (PropertyInfo item in collectionExpression.Type.GetInterfaces().SelectMany(@interface => @interface.GetProperties())) + { + properties.Add(item); + } + } + + foreach (PropertyInfo property in properties) + { + if (property.Name is "Count" or "Length") + { + return Expression.Property(collectionExpression, property); + } + } + } + + return null; + } + + public override Expression VisitResourceFieldChain(ResourceFieldChainExpression expression, QueryClauseBuilderContext context) + { + MemberExpression? property = null; + + foreach (ResourceFieldAttribute field in expression.Fields) + { + Expression parentAccessor = property ?? context.LambdaScope.Accessor; + Type propertyType = field.Property.DeclaringType!; + string propertyName = field.Property.Name; + + bool requiresUpCast = parentAccessor.Type != propertyType && parentAccessor.Type.IsAssignableFrom(propertyType); + Type parentType = requiresUpCast ? propertyType : parentAccessor.Type; + + if (parentType.GetProperty(propertyName) == null) + { + throw new InvalidOperationException($"Type '{parentType.Name}' does not contain a property named '{propertyName}'."); + } + + property = requiresUpCast + ? Expression.MakeMemberAccess(Expression.Convert(parentAccessor, propertyType), field.Property) + : Expression.Property(parentAccessor, propertyName); + } + + return property!; + } +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs new file mode 100644 index 0000000000..6344b91cb8 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs @@ -0,0 +1,100 @@ +using System.Linq.Expressions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// Immutable contextual state for *ClauseBuilder types. +/// +[PublicAPI] +public sealed class QueryClauseBuilderContext +{ + /// + /// The source expression to append to. + /// + public Expression Source { get; } + + /// + /// The resource type for . + /// + public ResourceType ResourceType { get; } + + /// + /// The extension type to generate calls on, typically or . + /// + public Type ExtensionType { get; } + + /// + /// The Entity Framework Core entity model. + /// + public IReadOnlyModel EntityModel { get; } + + /// + /// Used to produce unique names for lambda parameters. + /// + public LambdaScopeFactory LambdaScopeFactory { get; } + + /// + /// The lambda expression currently in scope. + /// + public LambdaScope LambdaScope { get; } + + /// + /// The outer driver for building query clauses. + /// + public IQueryableBuilder QueryableBuilder { get; } + + /// + /// Enables to pass custom state that you'd like to transfer between calls. + /// + public object? State { get; } + + public QueryClauseBuilderContext(Expression source, ResourceType resourceType, Type extensionType, IReadOnlyModel entityModel, + LambdaScopeFactory lambdaScopeFactory, LambdaScope lambdaScope, IQueryableBuilder queryableBuilder, object? state) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(extensionType); + ArgumentNullException.ThrowIfNull(entityModel); + ArgumentNullException.ThrowIfNull(lambdaScopeFactory); + ArgumentNullException.ThrowIfNull(lambdaScope); + ArgumentNullException.ThrowIfNull(queryableBuilder); + AssertSameType(source.Type, resourceType); + + Source = source; + ResourceType = resourceType; + LambdaScope = lambdaScope; + EntityModel = entityModel; + ExtensionType = extensionType; + LambdaScopeFactory = lambdaScopeFactory; + QueryableBuilder = queryableBuilder; + State = state; + } + + private static void AssertSameType(Type sourceType, ResourceType resourceType) + { + Type? sourceElementType = CollectionConverter.Instance.FindCollectionElementType(sourceType); + + if (sourceElementType != resourceType.ClrType) + { + throw new InvalidOperationException( + $"Internal error: Mismatch between expression type '{sourceElementType?.Name}' and resource type '{resourceType.ClrType.Name}'."); + } + } + + public QueryClauseBuilderContext WithSource(Expression source) + { + ArgumentNullException.ThrowIfNull(source); + + return new QueryClauseBuilderContext(source, ResourceType, ExtensionType, EntityModel, LambdaScopeFactory, LambdaScope, QueryableBuilder, State); + } + + public QueryClauseBuilderContext WithLambdaScope(LambdaScope lambdaScope) + { + ArgumentNullException.ThrowIfNull(lambdaScope); + + return new QueryClauseBuilderContext(Source, ResourceType, ExtensionType, EntityModel, LambdaScopeFactory, lambdaScope, QueryableBuilder, State); + } +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryLayerIncludeConverter.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryLayerIncludeConverter.cs new file mode 100644 index 0000000000..5d4eec3e31 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryLayerIncludeConverter.cs @@ -0,0 +1,78 @@ +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// Replaces all s with s in-place. +/// +public sealed class QueryLayerIncludeConverter : QueryExpressionVisitor +{ + private readonly QueryLayer _queryLayer; + + public QueryLayerIncludeConverter(QueryLayer queryLayer) + { + ArgumentNullException.ThrowIfNull(queryLayer); + + _queryLayer = queryLayer; + } + + public void ConvertIncludesToSelections() + { + if (_queryLayer.Include != null) + { + Visit(_queryLayer.Include, _queryLayer); + _queryLayer.Include = null; + } + + EnsureNonEmptySelection(_queryLayer); + } + + public override object? VisitInclude(IncludeExpression expression, QueryLayer queryLayer) + { + foreach (IncludeElementExpression element in expression.Elements.OrderBy(element => element.Relationship.PublicName)) + { + _ = Visit(element, queryLayer); + } + + return null; + } + + public override object? VisitIncludeElement(IncludeElementExpression expression, QueryLayer queryLayer) + { + QueryLayer subLayer = EnsureRelationshipInSelection(queryLayer, expression.Relationship); + + foreach (IncludeElementExpression nextIncludeElement in expression.Children.OrderBy(child => child.Relationship.PublicName)) + { + Visit(nextIncludeElement, subLayer); + } + + return null; + } + + private static QueryLayer EnsureRelationshipInSelection(QueryLayer queryLayer, RelationshipAttribute relationship) + { + queryLayer.Selection ??= new FieldSelection(); + FieldSelectors selectors = queryLayer.Selection.GetOrCreateSelectors(queryLayer.ResourceType); + + if (!selectors.ContainsField(relationship)) + { + selectors.IncludeRelationship(relationship, new QueryLayer(relationship.RightType)); + } + + QueryLayer subLayer = selectors[relationship]!; + EnsureNonEmptySelection(subLayer); + + return subLayer; + } + + private static void EnsureNonEmptySelection(QueryLayer queryLayer) + { + if (queryLayer.Selection == null) + { + // Empty selection indicates to fetch all scalar properties. + queryLayer.Selection = new FieldSelection(); + queryLayer.Selection.GetOrCreateSelectors(queryLayer.ResourceType); + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs new file mode 100644 index 0000000000..1a1af59749 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilder.cs @@ -0,0 +1,143 @@ +using System.Linq.Expressions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +[PublicAPI] +public class QueryableBuilder : IQueryableBuilder +{ + private readonly IIncludeClauseBuilder _includeClauseBuilder; + private readonly IWhereClauseBuilder _whereClauseBuilder; + private readonly IOrderClauseBuilder _orderClauseBuilder; + private readonly ISkipTakeClauseBuilder _skipTakeClauseBuilder; + private readonly ISelectClauseBuilder _selectClauseBuilder; + + public QueryableBuilder(IIncludeClauseBuilder includeClauseBuilder, IWhereClauseBuilder whereClauseBuilder, IOrderClauseBuilder orderClauseBuilder, + ISkipTakeClauseBuilder skipTakeClauseBuilder, ISelectClauseBuilder selectClauseBuilder) + { + ArgumentNullException.ThrowIfNull(includeClauseBuilder); + ArgumentNullException.ThrowIfNull(whereClauseBuilder); + ArgumentNullException.ThrowIfNull(orderClauseBuilder); + ArgumentNullException.ThrowIfNull(skipTakeClauseBuilder); + ArgumentNullException.ThrowIfNull(selectClauseBuilder); + + _includeClauseBuilder = includeClauseBuilder; + _whereClauseBuilder = whereClauseBuilder; + _orderClauseBuilder = orderClauseBuilder; + _skipTakeClauseBuilder = skipTakeClauseBuilder; + _selectClauseBuilder = selectClauseBuilder; + } + + public virtual Expression ApplyQuery(QueryLayer layer, QueryableBuilderContext context) + { + ArgumentNullException.ThrowIfNull(layer); + ArgumentNullException.ThrowIfNull(context); + AssertSameType(layer.ResourceType, context.ElementType); + + Expression expression = context.Source; + + if (layer.Include != null) + { + expression = ApplyInclude(expression, layer.Include, layer.ResourceType, context); + } + + if (layer.Filter != null) + { + expression = ApplyFilter(expression, layer.Filter, layer.ResourceType, context); + } + + if (layer.Sort != null) + { + expression = ApplySort(expression, layer.Sort, layer.ResourceType, context); + } + + if (layer.Pagination != null) + { + expression = ApplyPagination(expression, layer.Pagination, layer.ResourceType, context); + } + + if (layer.Selection != null) + { + expression = ApplySelection(expression, layer.Selection, layer.ResourceType, context); + } + + return expression; + } + + private static void AssertSameType(ResourceType resourceType, Type elementType) + { + if (elementType != resourceType.ClrType) + { + throw new InvalidOperationException( + $"Internal error: Mismatch between query layer type '{resourceType.ClrType.Name}' and query element type '{elementType.Name}'."); + } + } + + protected virtual Expression ApplyInclude(Expression source, IncludeExpression include, ResourceType resourceType, QueryableBuilderContext context) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(include); + ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(context); + + using LambdaScope lambdaScope = context.LambdaScopeFactory.CreateScope(context.ElementType); + QueryClauseBuilderContext clauseContext = context.CreateClauseContext(this, source, resourceType, lambdaScope); + + return _includeClauseBuilder.ApplyInclude(include, clauseContext); + } + + protected virtual Expression ApplyFilter(Expression source, FilterExpression filter, ResourceType resourceType, QueryableBuilderContext context) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(filter); + ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(context); + + using LambdaScope lambdaScope = context.LambdaScopeFactory.CreateScope(context.ElementType); + QueryClauseBuilderContext clauseContext = context.CreateClauseContext(this, source, resourceType, lambdaScope); + + return _whereClauseBuilder.ApplyWhere(filter, clauseContext); + } + + protected virtual Expression ApplySort(Expression source, SortExpression sort, ResourceType resourceType, QueryableBuilderContext context) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(sort); + ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(context); + + using LambdaScope lambdaScope = context.LambdaScopeFactory.CreateScope(context.ElementType); + QueryClauseBuilderContext clauseContext = context.CreateClauseContext(this, source, resourceType, lambdaScope); + + return _orderClauseBuilder.ApplyOrderBy(sort, clauseContext); + } + + protected virtual Expression ApplyPagination(Expression source, PaginationExpression pagination, ResourceType resourceType, QueryableBuilderContext context) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(pagination); + ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(context); + + using LambdaScope lambdaScope = context.LambdaScopeFactory.CreateScope(context.ElementType); + QueryClauseBuilderContext clauseContext = context.CreateClauseContext(this, source, resourceType, lambdaScope); + + return _skipTakeClauseBuilder.ApplySkipTake(pagination, clauseContext); + } + + protected virtual Expression ApplySelection(Expression source, FieldSelection selection, ResourceType resourceType, QueryableBuilderContext context) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(selection); + ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(context); + + using LambdaScope lambdaScope = context.LambdaScopeFactory.CreateScope(context.ElementType); + QueryClauseBuilderContext clauseContext = context.CreateClauseContext(this, source, resourceType, lambdaScope); + + return _selectClauseBuilder.ApplySelect(selection, clauseContext); + } +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilderContext.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilderContext.cs new file mode 100644 index 0000000000..aa8b1d8e74 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilderContext.cs @@ -0,0 +1,82 @@ +using System.Linq.Expressions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +/// Immutable contextual state for . +/// +[PublicAPI] +public sealed class QueryableBuilderContext +{ + /// + /// The source expression to append to. + /// + public Expression Source { get; } + + /// + /// The element type for . + /// + public Type ElementType { get; } + + /// + /// The extension type to generate calls on, typically or . + /// + public Type ExtensionType { get; } + + /// + /// The Entity Framework Core entity model. + /// + public IReadOnlyModel EntityModel { get; } + + /// + /// Used to produce unique names for lambda parameters. + /// + public LambdaScopeFactory LambdaScopeFactory { get; } + + /// + /// Enables to pass custom state that you'd like to transfer between calls. + /// + public object? State { get; } + + public QueryableBuilderContext(Expression source, Type elementType, Type extensionType, IReadOnlyModel entityModel, LambdaScopeFactory lambdaScopeFactory, + object? state) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(elementType); + ArgumentNullException.ThrowIfNull(extensionType); + ArgumentNullException.ThrowIfNull(entityModel); + ArgumentNullException.ThrowIfNull(lambdaScopeFactory); + + Source = source; + ElementType = elementType; + ExtensionType = extensionType; + EntityModel = entityModel; + LambdaScopeFactory = lambdaScopeFactory; + State = state; + } + + public static QueryableBuilderContext CreateRoot(IQueryable source, Type extensionType, IReadOnlyModel entityModel, object? state) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(extensionType); + ArgumentNullException.ThrowIfNull(entityModel); + + var lambdaScopeFactory = new LambdaScopeFactory(); + + return new QueryableBuilderContext(source.Expression, source.ElementType, extensionType, entityModel, lambdaScopeFactory, state); + } + + public QueryClauseBuilderContext CreateClauseContext(IQueryableBuilder queryableBuilder, Expression source, ResourceType resourceType, + LambdaScope lambdaScope) + { + ArgumentNullException.ThrowIfNull(queryableBuilder); + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(lambdaScope); + + return new QueryClauseBuilderContext(source, resourceType, ExtensionType, EntityModel, LambdaScopeFactory, lambdaScope, queryableBuilder, State); + } +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs new file mode 100644 index 0000000000..1be86e0bf5 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs @@ -0,0 +1,294 @@ +using System.Linq.Expressions; +using System.Reflection; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +[PublicAPI] +public class SelectClauseBuilder : QueryClauseBuilder, ISelectClauseBuilder +{ + private static readonly MethodInfo TypeGetTypeMethod = typeof(object).GetMethod("GetType")!; + private static readonly MethodInfo TypeOpEqualityMethod = typeof(Type).GetMethod("op_Equality")!; + private static readonly ConstantExpression NullConstant = Expression.Constant(null); + + private readonly IResourceFactory _resourceFactory; + + public SelectClauseBuilder(IResourceFactory resourceFactory) + { + ArgumentNullException.ThrowIfNull(resourceFactory); + + _resourceFactory = resourceFactory; + } + + public virtual Expression ApplySelect(FieldSelection selection, QueryClauseBuilderContext context) + { + ArgumentNullException.ThrowIfNull(selection); + + Expression bodyInitializer = CreateLambdaBodyInitializer(selection, context.ResourceType, false, context); + + LambdaExpression lambda = Expression.Lambda(bodyInitializer, context.LambdaScope.Parameter); + + return SelectExtensionMethodCall(context.ExtensionType, context.Source, context.LambdaScope.Parameter.Type, lambda); + } + + private Expression CreateLambdaBodyInitializer(FieldSelection selection, ResourceType resourceType, bool lambdaAccessorRequiresTestForNull, + QueryClauseBuilderContext context) + { + AssertSameType(context.LambdaScope.Accessor.Type, resourceType); + + IReadOnlyEntityType entityType = context.EntityModel.FindEntityType(resourceType.ClrType)!; + IReadOnlyEntityType[] concreteEntityTypes = entityType.GetConcreteDerivedTypesInclusive().ToArray(); + + Expression bodyInitializer = concreteEntityTypes.Length > 1 + ? CreateLambdaBodyInitializerForTypeHierarchy(selection, resourceType, concreteEntityTypes, context) + : CreateLambdaBodyInitializerForSingleType(selection, resourceType, context); + + if (!lambdaAccessorRequiresTestForNull) + { + return bodyInitializer; + } + + return TestForNull(context.LambdaScope.Accessor, bodyInitializer); + } + + private static void AssertSameType(Type lambdaAccessorType, ResourceType resourceType) + { + if (lambdaAccessorType != resourceType.ClrType) + { + throw new InvalidOperationException( + $"Internal error: Mismatch between lambda accessor type '{lambdaAccessorType.Name}' and resource type '{resourceType.ClrType.Name}'."); + } + } + + private Expression CreateLambdaBodyInitializerForTypeHierarchy(FieldSelection selection, ResourceType baseResourceType, + IEnumerable concreteEntityTypes, QueryClauseBuilderContext context) + { + IReadOnlySet resourceTypes = selection.GetResourceTypes(); + Expression rootCondition = context.LambdaScope.Accessor; + + foreach (IReadOnlyEntityType entityType in concreteEntityTypes) + { + ResourceType? resourceType = resourceTypes.SingleOrDefault(type => type.ClrType == entityType.ClrType); + + if (resourceType != null) + { + FieldSelectors fieldSelectors = selection.GetOrCreateSelectors(resourceType); + + if (!fieldSelectors.IsEmpty) + { + Dictionary.ValueCollection propertySelectors = + ToPropertySelectors(fieldSelectors, resourceType, entityType.ClrType, context.EntityModel); + + MemberBinding[] propertyAssignments = propertySelectors.Select(selector => CreatePropertyAssignment(selector, context)) + .Cast().ToArray(); + + NewExpression createInstance = _resourceFactory.CreateNewExpression(entityType.ClrType); + MemberInitExpression memberInit = Expression.MemberInit(createInstance, propertyAssignments); + UnaryExpression castToBaseType = Expression.Convert(memberInit, baseResourceType.ClrType); + + BinaryExpression typeCheck = CreateRuntimeTypeCheck(context.LambdaScope, entityType.ClrType); + rootCondition = Expression.Condition(typeCheck, castToBaseType, rootCondition); + } + } + } + + return rootCondition; + } + + private static BinaryExpression CreateRuntimeTypeCheck(LambdaScope lambdaScope, Type concreteClrType) + { + // Emitting "resource.GetType() == typeof(Article)" instead of "resource is Article" so we don't need to check for most-derived + // types first. This way, we can fall back to "anything else" at the end without worrying about order. + + Expression concreteTypeConstant = SystemExpressionBuilder.CloseOver(concreteClrType); + MethodCallExpression getTypeCall = Expression.Call(lambdaScope.Accessor, TypeGetTypeMethod); + + return Expression.MakeBinary(ExpressionType.Equal, getTypeCall, concreteTypeConstant, false, TypeOpEqualityMethod); + } + + private MemberInitExpression CreateLambdaBodyInitializerForSingleType(FieldSelection selection, ResourceType resourceType, + QueryClauseBuilderContext context) + { + FieldSelectors fieldSelectors = selection.GetOrCreateSelectors(resourceType); + + Dictionary.ValueCollection propertySelectors = + ToPropertySelectors(fieldSelectors, resourceType, context.LambdaScope.Accessor.Type, context.EntityModel); + + MemberBinding[] propertyAssignments = propertySelectors.Select(selector => CreatePropertyAssignment(selector, context)).Cast().ToArray(); + NewExpression createInstance = _resourceFactory.CreateNewExpression(context.LambdaScope.Accessor.Type); + return Expression.MemberInit(createInstance, propertyAssignments); + } + + private static Dictionary.ValueCollection ToPropertySelectors(FieldSelectors fieldSelectors, ResourceType resourceType, + Type elementType, IReadOnlyModel entityModel) + { + var propertySelectors = new Dictionary(); + + if (fieldSelectors.IsEmpty || fieldSelectors.ContainsReadOnlyAttribute || fieldSelectors.ContainsOnlyRelationships) + { + // If a read-only attribute is selected, its calculated value likely depends on another property, so fetch all scalar properties. + // And only selecting relationships implicitly means to fetch all scalar properties as well. + // Additionally, empty selectors (originating from eliminated includes) indicate to fetch all scalar properties too. + + IncludeAllScalarProperties(elementType, propertySelectors, entityModel); + } + + IncludeFields(fieldSelectors, propertySelectors); + IncludeEagerLoads(resourceType, propertySelectors); + + return propertySelectors.Values; + } + + private static void IncludeAllScalarProperties(Type elementType, Dictionary propertySelectors, IReadOnlyModel entityModel) + { + IReadOnlyEntityType entityType = entityModel.GetEntityTypes().Single(type => type.ClrType == elementType); + + foreach (IReadOnlyProperty property in entityType.GetProperties().Where(property => !property.IsShadowProperty())) + { + var propertySelector = new PropertySelector(property.PropertyInfo!); + IncludeWritableProperty(propertySelector, propertySelectors); + } + + foreach (IReadOnlyNavigation navigation in entityType.GetNavigations() + .Where(navigation => navigation.ForeignKey.IsOwnership && !navigation.IsShadowProperty())) + { + var propertySelector = new PropertySelector(navigation.PropertyInfo!); + IncludeWritableProperty(propertySelector, propertySelectors); + } + } + + private static void IncludeFields(FieldSelectors fieldSelectors, Dictionary propertySelectors) + { + foreach ((ResourceFieldAttribute resourceField, QueryLayer? nextLayer) in fieldSelectors) + { + var propertySelector = new PropertySelector(resourceField.Property, nextLayer); + IncludeWritableProperty(propertySelector, propertySelectors); + } + } + + private static void IncludeWritableProperty(PropertySelector propertySelector, Dictionary propertySelectors) + { + if (propertySelector.Property.SetMethod != null) + { + propertySelectors[propertySelector.Property] = propertySelector; + } + } + + private static void IncludeEagerLoads(ResourceType resourceType, Dictionary propertySelectors) + { + foreach (EagerLoadAttribute eagerLoad in resourceType.EagerLoads) + { + var propertySelector = new PropertySelector(eagerLoad.Property); + + // When an entity navigation property is decorated with both EagerLoadAttribute and RelationshipAttribute, + // it may already exist with a sub-layer. So do not overwrite in that case. + propertySelectors.TryAdd(propertySelector.Property, propertySelector); + } + } + + private MemberAssignment CreatePropertyAssignment(PropertySelector propertySelector, QueryClauseBuilderContext context) + { + bool requiresUpCast = context.LambdaScope.Accessor.Type != propertySelector.Property.DeclaringType && + context.LambdaScope.Accessor.Type.IsAssignableFrom(propertySelector.Property.DeclaringType); + + UnaryExpression? derivedAccessor = requiresUpCast ? Expression.Convert(context.LambdaScope.Accessor, propertySelector.Property.DeclaringType!) : null; + + MemberExpression propertyAccess = derivedAccessor != null + ? Expression.MakeMemberAccess(derivedAccessor, propertySelector.Property) + : Expression.Property(context.LambdaScope.Accessor, propertySelector.Property); + + Expression assignmentRightHandSide = propertyAccess; + + if (propertySelector.NextLayer != null) + { + QueryClauseBuilderContext rightHandSideContext = + derivedAccessor != null ? context.WithLambdaScope(context.LambdaScope.WithAccessor(derivedAccessor)) : context; + + assignmentRightHandSide = CreateAssignmentRightHandSideForLayer(propertySelector.NextLayer, propertyAccess, + propertySelector.Property, rightHandSideContext); + } + + return Expression.Bind(propertySelector.Property, assignmentRightHandSide); + } + + private Expression CreateAssignmentRightHandSideForLayer(QueryLayer layer, MemberExpression propertyAccess, PropertyInfo selectorPropertyInfo, + QueryClauseBuilderContext context) + { + Type? collectionElementType = CollectionConverter.Instance.FindCollectionElementType(selectorPropertyInfo.PropertyType); + Type bodyElementType = collectionElementType ?? selectorPropertyInfo.PropertyType; + + if (collectionElementType != null) + { + return CreateCollectionInitializer(selectorPropertyInfo, bodyElementType, layer, context); + } + + if (layer.Selection == null || layer.Selection.IsEmpty) + { + return propertyAccess; + } + + using LambdaScope initializerScope = context.LambdaScopeFactory.CreateScope(bodyElementType, propertyAccess); + QueryClauseBuilderContext initializerContext = context.WithLambdaScope(initializerScope); + return CreateLambdaBodyInitializer(layer.Selection, layer.ResourceType, true, initializerContext); + } + + private static MethodCallExpression CreateCollectionInitializer(PropertyInfo collectionProperty, Type elementType, QueryLayer layer, + QueryClauseBuilderContext context) + { + MemberExpression propertyExpression = Expression.Property(context.LambdaScope.Accessor, collectionProperty); + + var nestedContext = new QueryableBuilderContext(propertyExpression, elementType, typeof(Enumerable), context.EntityModel, context.LambdaScopeFactory, + context.State); + + Expression layerExpression = context.QueryableBuilder.ApplyQuery(layer, nestedContext); + + string operationName = CollectionConverter.Instance.TypeCanContainHashSet(collectionProperty.PropertyType) ? "ToHashSet" : "ToList"; + return CopyCollectionExtensionMethodCall(layerExpression, operationName, elementType); + } + + private static ConditionalExpression TestForNull(Expression expressionToTest, Expression ifFalseExpression) + { + BinaryExpression equalsNull = Expression.Equal(expressionToTest, NullConstant); + return Expression.Condition(equalsNull, Expression.Convert(NullConstant, expressionToTest.Type), ifFalseExpression); + } + + private static MethodCallExpression CopyCollectionExtensionMethodCall(Expression source, string operationName, Type elementType) + { + return Expression.Call(typeof(Enumerable), operationName, [elementType], source); + } + + private static MethodCallExpression SelectExtensionMethodCall(Type extensionType, Expression source, Type elementType, Expression selectBody) + { + Type[] typeArguments = + [ + elementType, + elementType + ]; + + return Expression.Call(extensionType, "Select", typeArguments, source, selectBody); + } + + private sealed class PropertySelector + { + public PropertyInfo Property { get; } + public QueryLayer? NextLayer { get; } + + public PropertySelector(PropertyInfo property, QueryLayer? nextLayer = null) + { + ArgumentNullException.ThrowIfNull(property); + + Property = property; + NextLayer = nextLayer; + } + + public override string ToString() + { + return $"Property: {(NextLayer != null ? $"{Property.Name}..." : Property.Name)}"; + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SkipTakeClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SkipTakeClauseBuilder.cs new file mode 100644 index 0000000000..ed062ee01c --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SkipTakeClauseBuilder.cs @@ -0,0 +1,43 @@ +using System.Linq.Expressions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +[PublicAPI] +public class SkipTakeClauseBuilder : QueryClauseBuilder, ISkipTakeClauseBuilder +{ + public virtual Expression ApplySkipTake(PaginationExpression expression, QueryClauseBuilderContext context) + { + ArgumentNullException.ThrowIfNull(expression); + + return Visit(expression, context); + } + + public override Expression VisitPagination(PaginationExpression expression, QueryClauseBuilderContext context) + { + Expression skipTakeExpression = context.Source; + + if (expression.PageSize != null) + { + int skipValue = (expression.PageNumber.OneBasedValue - 1) * expression.PageSize.Value; + + if (skipValue > 0) + { + skipTakeExpression = ExtensionMethodCall(skipTakeExpression, "Skip", skipValue, context); + } + + skipTakeExpression = ExtensionMethodCall(skipTakeExpression, "Take", expression.PageSize.Value, context); + } + + return skipTakeExpression; + } + + private static MethodCallExpression ExtensionMethodCall(Expression source, string operationName, int value, QueryClauseBuilderContext context) + { + Expression constant = SystemExpressionBuilder.CloseOver(value); + + return Expression.Call(context.ExtensionType, operationName, [context.LambdaScope.Parameter.Type], source, constant); + } +} diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs new file mode 100644 index 0000000000..772b8dd18d --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/WhereClauseBuilder.cs @@ -0,0 +1,249 @@ +using System.Collections; +using System.Linq.Expressions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries.QueryableBuilding; + +/// +[PublicAPI] +public class WhereClauseBuilder : QueryClauseBuilder, IWhereClauseBuilder +{ + private static readonly ConstantExpression NullConstant = Expression.Constant(null); + + public virtual Expression ApplyWhere(FilterExpression filter, QueryClauseBuilderContext context) + { + ArgumentNullException.ThrowIfNull(filter); + + LambdaExpression lambda = GetPredicateLambda(filter, context); + + return WhereExtensionMethodCall(lambda, context); + } + + private LambdaExpression GetPredicateLambda(FilterExpression filter, QueryClauseBuilderContext context) + { + Expression body = Visit(filter, context); + return Expression.Lambda(body, context.LambdaScope.Parameter); + } + + private static MethodCallExpression WhereExtensionMethodCall(LambdaExpression predicate, QueryClauseBuilderContext context) + { + return Expression.Call(context.ExtensionType, "Where", [context.LambdaScope.Parameter.Type], context.Source, predicate); + } + + public override Expression VisitHas(HasExpression expression, QueryClauseBuilderContext context) + { + Expression property = Visit(expression.TargetCollection, context); + + Type? elementType = CollectionConverter.Instance.FindCollectionElementType(property.Type); + + if (elementType == null) + { + throw new InvalidOperationException("Expression must be a collection."); + } + + Expression? predicate = null; + + if (expression.Filter != null) + { + ResourceType resourceType = ((HasManyAttribute)expression.TargetCollection.Fields[^1]).RightType; + + using LambdaScope lambdaScope = context.LambdaScopeFactory.CreateScope(elementType); + + var nestedContext = new QueryClauseBuilderContext(property, resourceType, typeof(Enumerable), context.EntityModel, context.LambdaScopeFactory, + lambdaScope, context.QueryableBuilder, context.State); + + predicate = GetPredicateLambda(expression.Filter, nestedContext); + } + + return AnyExtensionMethodCall(elementType, property, predicate); + } + + private static MethodCallExpression AnyExtensionMethodCall(Type elementType, Expression source, Expression? predicate) + { + return predicate != null + ? Expression.Call(typeof(Enumerable), "Any", [elementType], source, predicate) + : Expression.Call(typeof(Enumerable), "Any", [elementType], source); + } + + public override Expression VisitIsType(IsTypeExpression expression, QueryClauseBuilderContext context) + { + Expression property = expression.TargetToOneRelationship != null ? Visit(expression.TargetToOneRelationship, context) : context.LambdaScope.Accessor; + TypeBinaryExpression typeCheck = Expression.TypeIs(property, expression.DerivedType.ClrType); + + if (expression.Child == null) + { + return typeCheck; + } + + UnaryExpression derivedAccessor = Expression.Convert(property, expression.DerivedType.ClrType); + + QueryClauseBuilderContext derivedContext = context.WithLambdaScope(context.LambdaScope.WithAccessor(derivedAccessor)); + Expression filter = Visit(expression.Child, derivedContext); + + return Expression.AndAlso(typeCheck, filter); + } + + public override Expression VisitMatchText(MatchTextExpression expression, QueryClauseBuilderContext context) + { + Expression property = Visit(expression.TargetAttribute, context); + + if (property.Type != typeof(string)) + { + throw new InvalidOperationException("Expression must be a string."); + } + + Expression text = Visit(expression.TextValue, context); + + return expression.MatchKind switch + { + TextMatchKind.StartsWith => Expression.Call(property, "StartsWith", null, text), + TextMatchKind.EndsWith => Expression.Call(property, "EndsWith", null, text), + _ => Expression.Call(property, "Contains", null, text) + }; + } + + public override Expression VisitAny(AnyExpression expression, QueryClauseBuilderContext context) + { + Expression property = Visit(expression.TargetAttribute, context); + + var valueList = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(property.Type))!; + + foreach (LiteralConstantExpression constant in expression.Constants) + { + valueList.Add(constant.TypedValue); + } + + ConstantExpression collection = Expression.Constant(valueList); + return ContainsExtensionMethodCall(collection, property); + } + + private static MethodCallExpression ContainsExtensionMethodCall(Expression collection, Expression value) + { + return Expression.Call(typeof(Enumerable), "Contains", [value.Type], collection, value); + } + + public override Expression VisitLogical(LogicalExpression expression, QueryClauseBuilderContext context) + { + var termQueue = new Queue(expression.Terms.Select(filter => Visit(filter, context))); + + return expression.Operator switch + { + LogicalOperator.And => Compose(termQueue, Expression.AndAlso), + LogicalOperator.Or => Compose(termQueue, Expression.OrElse), + _ => throw new InvalidOperationException($"Unknown logical operator '{expression.Operator}'.") + }; + } + + private static BinaryExpression Compose(Queue argumentQueue, Func applyOperator) + { + Expression left = argumentQueue.Dequeue(); + Expression right = argumentQueue.Dequeue(); + + BinaryExpression tempExpression = applyOperator(left, right); + + while (argumentQueue.Count > 0) + { + Expression nextArgument = argumentQueue.Dequeue(); + tempExpression = applyOperator(tempExpression, nextArgument); + } + + return tempExpression; + } + + public override Expression VisitNot(NotExpression expression, QueryClauseBuilderContext context) + { + Expression child = Visit(expression.Child, context); + return Expression.Not(child); + } + + public override Expression VisitComparison(ComparisonExpression expression, QueryClauseBuilderContext context) + { + Type commonType = ResolveCommonType(expression.Left, expression.Right, context); + + Expression left = WrapInConvert(Visit(expression.Left, context), commonType); + Expression right = WrapInConvert(Visit(expression.Right, context), commonType); + + return expression.Operator switch + { + ComparisonOperator.Equals => Expression.Equal(left, right), + ComparisonOperator.LessThan => Expression.LessThan(left, right), + ComparisonOperator.LessOrEqual => Expression.LessThanOrEqual(left, right), + ComparisonOperator.GreaterThan => Expression.GreaterThan(left, right), + ComparisonOperator.GreaterOrEqual => Expression.GreaterThanOrEqual(left, right), + _ => throw new InvalidOperationException($"Unknown comparison operator '{expression.Operator}'.") + }; + } + + private Type ResolveCommonType(QueryExpression left, QueryExpression right, QueryClauseBuilderContext context) + { + Type leftType = ResolveFixedType(left, context); + + if (RuntimeTypeConverter.CanContainNull(leftType)) + { + return leftType; + } + + if (right is NullConstantExpression) + { + return typeof(Nullable<>).MakeGenericType(leftType); + } + + Type? rightType = TryResolveFixedType(right, context); + + if (rightType != null && RuntimeTypeConverter.CanContainNull(rightType)) + { + return rightType; + } + + return leftType; + } + + private Type ResolveFixedType(QueryExpression expression, QueryClauseBuilderContext context) + { + Expression result = Visit(expression, context); + return result.Type; + } + + private Type? TryResolveFixedType(QueryExpression expression, QueryClauseBuilderContext context) + { + if (expression is CountExpression) + { + return typeof(int); + } + + if (expression is ResourceFieldChainExpression chain) + { + Expression child = Visit(chain, context); + return child.Type; + } + + return null; + } + + private static Expression WrapInConvert(Expression expression, Type targetType) + { + try + { + return expression.Type != targetType ? Expression.Convert(expression, targetType) : expression; + } + catch (InvalidOperationException exception) + { + throw new InvalidQueryException("Query creation failed due to incompatible types.", exception); + } + } + + public override Expression VisitNullConstant(NullConstantExpression expression, QueryClauseBuilderContext context) + { + return NullConstant; + } + + public override Expression VisitLiteralConstant(LiteralConstantExpression expression, QueryClauseBuilderContext context) + { + return SystemExpressionBuilder.CloseOver(expression.TypedValue); + } +} diff --git a/src/JsonApiDotNetCore/Queries/SparseFieldSetCache.cs b/src/JsonApiDotNetCore/Queries/SparseFieldSetCache.cs new file mode 100644 index 0000000000..0df2d5af75 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/SparseFieldSetCache.cs @@ -0,0 +1,156 @@ +using System.Collections.Concurrent; +using System.Collections.Immutable; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries; + +/// +public sealed class SparseFieldSetCache : ISparseFieldSetCache +{ + private static readonly ConcurrentDictionary ViewableFieldSetCache = new(); + + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; + private readonly Lazy>> _lazySourceTable; + private readonly Dictionary> _visitedTable = []; + + public SparseFieldSetCache(IEnumerable constraintProviders, IResourceDefinitionAccessor resourceDefinitionAccessor) + { + ArgumentNullException.ThrowIfNull(constraintProviders); + ArgumentNullException.ThrowIfNull(resourceDefinitionAccessor); + + _resourceDefinitionAccessor = resourceDefinitionAccessor; + _lazySourceTable = new Lazy>>(() => BuildSourceTable(constraintProviders)); + } + + private static Dictionary> BuildSourceTable( + IEnumerable constraintProviders) + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:wrap_before_first_method_call true + + KeyValuePair[] sparseFieldTables = constraintProviders + .SelectMany(provider => provider.GetConstraints()) + .Where(constraint => constraint.Scope == null) + .Select(constraint => constraint.Expression) + .OfType() + .Select(expression => expression.Table) + .SelectMany(table => table) + .ToArray(); + + // @formatter:wrap_before_first_method_call restore + // @formatter:wrap_chained_method_calls restore + + var mergedTable = new Dictionary.Builder>(); + + foreach ((ResourceType resourceType, SparseFieldSetExpression sparseFieldSet) in sparseFieldTables) + { + if (!mergedTable.TryGetValue(resourceType, out ImmutableHashSet.Builder? builder)) + { + builder = ImmutableHashSet.CreateBuilder(); + mergedTable[resourceType] = builder; + } + + AddSparseFieldsToSet(sparseFieldSet.Fields, builder); + } + + return mergedTable.ToDictionary(pair => pair.Key, pair => pair.Value.ToImmutable()); + } + + private static void AddSparseFieldsToSet(IImmutableSet sparseFieldsToAdd, + ImmutableHashSet.Builder sparseFieldSetBuilder) + { + foreach (ResourceFieldAttribute field in sparseFieldsToAdd) + { + sparseFieldSetBuilder.Add(field); + } + } + + /// + public IImmutableSet GetSparseFieldSetForQuery(ResourceType resourceType) + { + ArgumentNullException.ThrowIfNull(resourceType); + + if (!_visitedTable.TryGetValue(resourceType, out IImmutableSet? outputFields)) + { + SparseFieldSetExpression? inputExpression = + _lazySourceTable.Value.TryGetValue(resourceType, out ImmutableHashSet? inputFields) + ? new SparseFieldSetExpression(inputFields) + : null; + + SparseFieldSetExpression? outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceType, inputExpression); + outputFields = outputExpression == null ? ImmutableHashSet.Empty : outputExpression.Fields; + + _visitedTable[resourceType] = outputFields; + } + + return outputFields; + } + + /// + public IImmutableSet GetIdAttributeSetForRelationshipQuery(ResourceType resourceType) + { + ArgumentNullException.ThrowIfNull(resourceType); + + AttrAttribute idAttribute = resourceType.GetAttributeByPropertyName(nameof(Identifiable.Id)); + var inputExpression = new SparseFieldSetExpression(ImmutableHashSet.Create(idAttribute)); + + // Intentionally not cached, as we are fetching ID only (ignoring any sparse fieldset that came from query string). + SparseFieldSetExpression? outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceType, inputExpression); + + ImmutableHashSet outputAttributes = outputExpression == null + ? ImmutableHashSet.Empty + : outputExpression.Fields.OfType().ToImmutableHashSet(); + + outputAttributes = outputAttributes.Add(idAttribute); + return outputAttributes; + } + + /// + public IImmutableSet GetSparseFieldSetForSerializer(ResourceType resourceType) + { + ArgumentNullException.ThrowIfNull(resourceType); + + if (!_visitedTable.TryGetValue(resourceType, out IImmutableSet? outputFields)) + { + SparseFieldSetExpression inputExpression = + _lazySourceTable.Value.TryGetValue(resourceType, out ImmutableHashSet? inputFields) + ? new SparseFieldSetExpression(inputFields) + : GetCachedViewableFieldSet(resourceType); + + SparseFieldSetExpression? outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceType, inputExpression); + + outputFields = outputExpression == null + ? GetCachedViewableFieldSet(resourceType).Fields + : inputExpression.Fields.Intersect(outputExpression.Fields); + + _visitedTable[resourceType] = outputFields; + } + + return outputFields; + } + + private static SparseFieldSetExpression GetCachedViewableFieldSet(ResourceType resourceType) + { + if (!ViewableFieldSetCache.TryGetValue(resourceType, out SparseFieldSetExpression? fieldSet)) + { + ImmutableHashSet viewableFields = GetViewableFields(resourceType); + fieldSet = new SparseFieldSetExpression(viewableFields); + ViewableFieldSetCache[resourceType] = fieldSet; + } + + return fieldSet; + } + + private static ImmutableHashSet GetViewableFields(ResourceType resourceType) + { + return resourceType.Fields.Where(nextField => !nextField.IsViewBlocked()).ToImmutableHashSet(); + } + + public void Reset() + { + _visitedTable.Clear(); + } +} diff --git a/src/JsonApiDotNetCore/Queries/SystemExpressionBuilder.cs b/src/JsonApiDotNetCore/Queries/SystemExpressionBuilder.cs new file mode 100644 index 0000000000..72a30a060a --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/SystemExpressionBuilder.cs @@ -0,0 +1,34 @@ +using System.Linq.Expressions; +using System.Reflection; + +#pragma warning disable AV1008 + +namespace JsonApiDotNetCore.Queries; + +internal static class SystemExpressionBuilder +{ + private static readonly MethodInfo CloseOverOpenMethod = + typeof(SystemExpressionBuilder).GetMethods().Single(method => method is { Name: nameof(CloseOver), IsGenericMethod: true }); + + // To enable efficient query plan caching, inline constants (that vary per request) should be converted into query parameters. + // https://stackoverflow.com/questions/54075758/building-a-parameterized-entityframework-core-expression + // + // CloseOver can be used to change a query like: + // SELECT ... FROM ... WHERE x."Age" = 3 + // into: + // SELECT ... FROM ... WHERE x."Age" = @p0 + + public static Expression CloseOver(object value) + { + ArgumentNullException.ThrowIfNull(value); + + MethodInfo closeOverClosedMethod = CloseOverOpenMethod.MakeGenericMethod(value.GetType()); + return (Expression)closeOverClosedMethod.Invoke(null, [value])!; + } + + public static Expression CloseOver(T value) + { + // From https://github.com/dotnet/efcore/issues/28151#issuecomment-1374480257. + return ((Expression>)(() => value)).Body; + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs new file mode 100644 index 0000000000..52e31c8dc7 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/BuiltInPatterns.cs @@ -0,0 +1,16 @@ +using JetBrains.Annotations; + +#pragma warning disable AV1008 // Class should not be static + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +[PublicAPI] +public static class BuiltInPatterns +{ + public static FieldChainPattern SingleField { get; } = FieldChainPattern.Parse("F"); + public static FieldChainPattern ToOneChain { get; } = FieldChainPattern.Parse("O+"); + public static FieldChainPattern ToOneChainEndingInAttribute { get; } = FieldChainPattern.Parse("O*A"); + public static FieldChainPattern ToOneChainEndingInAttributeOrToOne { get; } = FieldChainPattern.Parse("O*[OA]"); + public static FieldChainPattern ToOneChainEndingInToMany { get; } = FieldChainPattern.Parse("O*M"); + public static FieldChainPattern RelationshipChainEndingInToMany { get; } = FieldChainPattern.Parse("R*M"); +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainFormatException.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainFormatException.cs new file mode 100644 index 0000000000..f7a81aa443 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainFormatException.cs @@ -0,0 +1,13 @@ +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// The exception that is thrown when the format of a dot-separated resource field chain is invalid. +/// +internal sealed class FieldChainFormatException(int position, string message) + : FormatException(message) +{ + /// + /// Gets the zero-based error position in the field chain, or at its end. + /// + public int Position { get; } = position; +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainParser.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainParser.cs new file mode 100644 index 0000000000..0bbea6b63f --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainParser.cs @@ -0,0 +1,34 @@ +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// Parses a dot-separated resource field chain from text into a list of field names. +/// +internal sealed class FieldChainParser +{ + public IEnumerable Parse(string source) + { + ArgumentNullException.ThrowIfNull(source); + + if (source.Length > 0) + { + var fields = new List(source.Split('.')); + int position = 0; + + foreach (string field in fields) + { + string trimmed = field.Trim(); + + if (field.Length == 0 || trimmed.Length != field.Length) + { + throw new FieldChainFormatException(position, "Field name expected."); + } + + position += field.Length + 1; + } + + return fields; + } + + return Array.Empty(); + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs new file mode 100644 index 0000000000..e3c15ebdbe --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPattern.cs @@ -0,0 +1,180 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// A pattern that can be matched against a dot-separated resource field chain. +/// +[PublicAPI] +public sealed class FieldChainPattern +{ + /// + /// Gets the set of possible resource field types. + /// + internal FieldTypes Choices { get; } + + /// + /// Indicates whether this pattern segment must match at least one resource field. + /// + internal bool AtLeastOne { get; } + + /// + /// Indicates whether this pattern can match multiple resource fields. + /// + internal bool AtMostOne { get; } + + /// + /// Gets the next pattern segment in the chain, or null if at the end. + /// + internal FieldChainPattern? Next { get; } + + internal FieldChainPattern(FieldTypes choices, bool atLeastOne, bool atMostOne, FieldChainPattern? next) + { + if (choices == FieldTypes.None) + { + throw new ArgumentException("The set of choices cannot be empty.", nameof(choices)); + } + + Choices = choices; + AtLeastOne = atLeastOne; + AtMostOne = atMostOne; + Next = next; + } + + /// + /// Creates a pattern from the specified text that can be matched against. + /// + /// + /// Patterns are similar to regular expressions, but a lot simpler. They consist of a sequence of terms. A term can be a single character or a character + /// choice. A term is optionally followed by a quantifier. + ///

+ /// The following characters can be used: + /// + /// + /// M + /// + /// Matches a to-many relationship. + /// + /// + /// + /// O + /// + /// Matches a to-one relationship. + /// + /// + /// + /// R + /// + /// Matches a relationship. + /// + /// + /// + /// A + /// + /// Matches an attribute. + /// + /// + /// + /// F + /// + /// Matches a field. + /// + /// + /// + ///

+ ///

+ /// A character choice contains a set of characters, surrounded by brackets. One of the choices must match. For example, "[MO]" matches a relationship, + /// but not at attribute. + ///

+ /// A quantifier is used to indicate how many times its term directly to the left can occur. + /// + /// + /// ? + /// + /// Matches its preceding term zero or one times. + /// + /// + /// + /// * + /// + /// Matches its preceding term zero or more times. + /// + /// + /// + /// + + /// + /// Matches its preceding term one or more times. + /// + /// + /// + /// + /// For example, the pattern "M?O*A" matches "children.parent.name", "parent.parent.name" and "name". + /// + ///
+ /// + /// The pattern is invalid. + /// + public static FieldChainPattern Parse(string pattern) + { + var parser = new PatternParser(); + return parser.Parse(pattern); + } + + /// + /// Matches the specified resource field chain against this pattern. + /// + /// + /// The dot-separated chain of resource field names. + /// + /// + /// The parent resource type to start matching from. + /// + /// + /// Match options, defaults to . + /// + /// + /// When provided, logs the matching steps at level. + /// + /// + /// The match result. + /// + public PatternMatchResult Match(string fieldChain, ResourceType resourceType, FieldChainPatternMatchOptions options = FieldChainPatternMatchOptions.None, + ILoggerFactory? loggerFactory = null) + { + ArgumentNullException.ThrowIfNull(fieldChain); + ArgumentNullException.ThrowIfNull(resourceType); + + ILogger logger = loggerFactory == null ? NullLogger.Instance : loggerFactory.CreateLogger(); + var matcher = new PatternMatcher(this, options, logger); + return matcher.Match(fieldChain, resourceType); + } + + /// + /// Returns only the first segment of this pattern chain. Used for diagnostic messages. + /// + internal FieldChainPattern WithoutNext() + { + return Next == null ? this : new FieldChainPattern(Choices, AtLeastOne, AtMostOne, null); + } + + /// + /// Gets the text representation of this pattern. + /// + public override string ToString() + { + var formatter = new PatternTextFormatter(this); + return formatter.Format(); + } + + /// + /// Gets a human-readable description of this pattern. + /// + public string GetDescription() + { + var formatter = new PatternDescriptionFormatter(this); + return formatter.Format(); + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPatternMatchOptions.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPatternMatchOptions.cs new file mode 100644 index 0000000000..645f53ef50 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldChainPatternMatchOptions.cs @@ -0,0 +1,18 @@ +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// Indicates how to perform matching a pattern against a resource field chain. +/// +[Flags] +public enum FieldChainPatternMatchOptions +{ + /// + /// Specifies that no options are set. + /// + None = 0, + + /// + /// Specifies to include fields on derived types in the search for a matching field. + /// + AllowDerivedTypes = 1 +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypeExtensions.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypeExtensions.cs new file mode 100644 index 0000000000..8c18c4448e --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypeExtensions.cs @@ -0,0 +1,56 @@ +using System.Text; + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +internal static class FieldTypeExtensions +{ + public static void WriteTo(this FieldTypes choices, StringBuilder builder, bool pluralize, bool prefix) + { + int startOffset = builder.Length; + + if (choices.HasFlag(FieldTypes.ToManyRelationship) && !choices.HasFlag(FieldTypes.Relationship)) + { + WriteChoice("to-many relationship", pluralize, prefix, false, builder, startOffset); + } + + if (choices.HasFlag(FieldTypes.ToOneRelationship) && !choices.HasFlag(FieldTypes.Relationship)) + { + WriteChoice("to-one relationship", pluralize, prefix, false, builder, startOffset); + } + + if (choices.HasFlag(FieldTypes.Attribute) && !choices.HasFlag(FieldTypes.Relationship)) + { + WriteChoice("attribute", pluralize, prefix, true, builder, startOffset); + } + + if (choices.HasFlag(FieldTypes.Relationship) && !choices.HasFlag(FieldTypes.Field)) + { + WriteChoice("relationship", pluralize, prefix, false, builder, startOffset); + } + + if (choices.HasFlag(FieldTypes.Field)) + { + WriteChoice("field", pluralize, prefix, false, builder, startOffset); + } + } + + private static void WriteChoice(string typeText, bool pluralize, bool prefix, bool isAttribute, StringBuilder builder, int startOffset) + { + if (builder.Length > startOffset) + { + builder.Append(" or "); + } + + if (prefix && !pluralize) + { + builder.Append(isAttribute ? "an " : "a "); + } + + builder.Append(typeText); + + if (pluralize) + { + builder.Append('s'); + } + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypes.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypes.cs new file mode 100644 index 0000000000..8011ec3ec4 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/FieldTypes.cs @@ -0,0 +1,12 @@ +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +[Flags] +internal enum FieldTypes +{ + None = 0, + Attribute = 1, + ToOneRelationship = 1 << 1, + ToManyRelationship = 1 << 2, + Relationship = ToOneRelationship | ToManyRelationship, + Field = Attribute | Relationship +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchError.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchError.cs new file mode 100644 index 0000000000..3707151560 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchError.cs @@ -0,0 +1,158 @@ +using System.Text; +using JsonApiDotNetCore.Configuration; + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// Indicates a failure to match a pattern against a resource field chain. +/// +internal sealed class MatchError +{ + /// + /// Gets the match failure message. + /// + public string Message { get; } + + /// + /// Gets the zero-based position in the resource field chain, or at its end, where the failure occurred. + /// + public int Position { get; } + + /// + /// Indicates whether this error occurred due to an invalid field chain, irrespective of greedy matching. + /// + public bool IsFieldChainError { get; } + + private MatchError(string message, int position, bool isFieldChainError) + { + Message = message; + Position = position; + IsFieldChainError = isFieldChainError; + } + + public static MatchError CreateForBrokenFieldChain(FieldChainFormatException exception) + { + return new MatchError(exception.Message, exception.Position, true); + } + + public static MatchError CreateForUnknownField(int position, ResourceType? resourceType, string publicName, bool allowDerivedTypes) + { + bool hasDerivedTypes = allowDerivedTypes && resourceType is { DirectlyDerivedTypes.Count: > 0 }; + + var builder = new MessageBuilder(); + + builder.WriteDoesNotExist(publicName); + builder.WriteResourceType(resourceType); + builder.WriteOrDerivedTypes(hasDerivedTypes); + builder.WriteEnd(); + + string message = builder.ToString(); + return new MatchError(message, position, true); + } + + public static MatchError CreateForMultipleDerivedTypes(int position, ResourceType resourceType, string publicName) + { + string message = $"Field '{publicName}' is defined on multiple types that derive from resource type '{resourceType}'."; + return new MatchError(message, position, true); + } + + public static MatchError CreateForFieldTypeMismatch(int position, ResourceType? resourceType, FieldTypes choices) + { + var builder = new MessageBuilder(); + + builder.WriteChoices(choices); + builder.WriteResourceType(resourceType); + builder.WriteExpected(); + builder.WriteEnd(); + + string message = builder.ToString(); + return new MatchError(message, position, false); + } + + public static MatchError CreateForTooMuchInput(int position, ResourceType? resourceType, FieldTypes choices) + { + var builder = new MessageBuilder(); + + builder.WriteEndOfChain(); + + if (choices != FieldTypes.None) + { + builder.WriteOr(); + builder.WriteChoices(choices); + builder.WriteResourceType(resourceType); + } + + builder.WriteExpected(); + builder.WriteEnd(); + + string message = builder.ToString(); + return new MatchError(message, position, false); + } + + public override string ToString() + { + return Message; + } + + private sealed class MessageBuilder + { + private readonly StringBuilder _builder = new(); + + public void WriteDoesNotExist(string publicName) + { + _builder.Append($"Field '{publicName}' does not exist"); + } + + public void WriteOrDerivedTypes(bool hasDerivedTypes) + { + if (hasDerivedTypes) + { + _builder.Append(" or any of its derived types"); + } + } + + public void WriteEndOfChain() + { + _builder.Append("End of field chain"); + } + + public void WriteOr() + { + _builder.Append(" or "); + } + + public void WriteChoices(FieldTypes choices) + { + bool firstCharToUpper = _builder.Length == 0; + choices.WriteTo(_builder, false, false); + + if (firstCharToUpper && _builder.Length > 0) + { + _builder[0] = char.ToUpperInvariant(_builder[0]); + } + } + + public void WriteResourceType(ResourceType? resourceType) + { + if (resourceType != null) + { + _builder.Append($" on resource type '{resourceType}'"); + } + } + + public void WriteExpected() + { + _builder.Append(" expected"); + } + + public void WriteEnd() + { + _builder.Append('.'); + } + + public override string ToString() + { + return _builder.ToString(); + } + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs new file mode 100644 index 0000000000..678850e4c4 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchState.cs @@ -0,0 +1,282 @@ +using System.Collections.Immutable; +using System.Text; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// Immutable intermediate state, used while matching a resource field chain against a pattern. +/// +internal sealed class MatchState +{ + /// + /// The successful parent match. Chaining together with those of parents produces the full match. + /// + private readonly MatchState? _parentMatch; + + /// + /// The remaining chain of pattern segments. The first segment is being matched against. + /// + public FieldChainPattern? Pattern { get; } + + /// + /// The resource type to find the next field on. + /// + public ResourceType? ResourceType { get; } + + /// + /// The fields matched against this pattern segment. + /// + public IImmutableList FieldsMatched { get; } + + /// + /// The remaining fields to be matched against the remaining pattern chain. + /// + public LinkedListNode? FieldsRemaining { get; } + + /// + /// The error in case matching this pattern segment failed. + /// + public MatchError? Error { get; } + + private MatchState(FieldChainPattern? pattern, ResourceType? resourceType, IImmutableList fieldsMatched, + LinkedListNode? fieldsRemaining, MatchError? error, MatchState? parentMatch) + { + Pattern = pattern; + ResourceType = resourceType; + FieldsMatched = fieldsMatched; + FieldsRemaining = fieldsRemaining; + Error = error; + _parentMatch = parentMatch; + } + + public static MatchState Create(FieldChainPattern pattern, string fieldChainText, ResourceType resourceType) + { + ArgumentNullException.ThrowIfNull(pattern); + ArgumentNullException.ThrowIfNull(fieldChainText); + ArgumentNullException.ThrowIfNull(resourceType); + + try + { + var parser = new FieldChainParser(); + IEnumerable fieldChain = parser.Parse(fieldChainText); + + LinkedListNode? remainingHead = new LinkedList(fieldChain).First; + return new MatchState(pattern, resourceType, ImmutableArray.Empty, remainingHead, null, null); + } + catch (FieldChainFormatException exception) + { + var error = MatchError.CreateForBrokenFieldChain(exception); + return new MatchState(pattern, resourceType, ImmutableArray.Empty, null, error, null); + } + } + + /// + /// Returns a new state for successfully matching the top-level remaining field. Moves one position forward in the resource field chain. + /// + public MatchState SuccessMoveForwardOneField(ResourceFieldAttribute matchedValue) + { + ArgumentNullException.ThrowIfNull(matchedValue); + AssertIsSuccess(this); + + IImmutableList fieldsMatched = FieldsMatched.Add(matchedValue); + LinkedListNode? fieldsRemaining = FieldsRemaining!.Next; + ResourceType? resourceType = matchedValue is RelationshipAttribute relationship ? relationship.RightType : null; + + return new MatchState(Pattern, resourceType, fieldsMatched, fieldsRemaining, null, _parentMatch); + } + + /// + /// Returns a new state for matching the next pattern segment. + /// + public MatchState SuccessMoveToNextPattern() + { + AssertIsSuccess(this); + AssertHasPattern(); + + return new MatchState(Pattern!.Next, ResourceType, ImmutableArray.Empty, FieldsRemaining, null, this); + } + + /// + /// Returns a new state for match failure due to an unknown field. + /// + public MatchState FailureForUnknownField(string publicName, bool allowDerivedTypes) + { + int position = GetAbsolutePosition(true); + var error = MatchError.CreateForUnknownField(position, ResourceType, publicName, allowDerivedTypes); + + return Failure(error); + } + + /// + /// Returns a new state for match failure because the field exists on multiple derived types. + /// + public MatchState FailureForMultipleDerivedTypes(string publicName) + { + AssertHasResourceType(); + + int position = GetAbsolutePosition(true); + var error = MatchError.CreateForMultipleDerivedTypes(position, ResourceType!, publicName); + + return Failure(error); + } + + /// + /// Returns a new state for match failure because the field type is not one of the pattern choices. + /// + public MatchState FailureForFieldTypeMismatch(FieldTypes choices, FieldTypes chosenFieldType) + { + FieldTypes allChoices = IncludeChoicesFromParentMatch(choices); + int position = GetAbsolutePosition(chosenFieldType != FieldTypes.None); + var error = MatchError.CreateForFieldTypeMismatch(position, ResourceType, allChoices); + + return Failure(error); + } + + /// + /// Combines the choices of this pattern segment with choices from parent matches, if they can match more. + /// + private FieldTypes IncludeChoicesFromParentMatch(FieldTypes choices) + { + if (choices == FieldTypes.Field) + { + // We already match everything, there's no point in looking deeper. + return choices; + } + + if (_parentMatch is { Pattern: not null }) + { + // The choices from the parent pattern segment are available when: + // - The parent pattern can match multiple times. + // - The parent pattern is optional and matched nothing. + if (!_parentMatch.Pattern.AtMostOne || (!_parentMatch.Pattern.AtLeastOne && _parentMatch.FieldsMatched.Count == 0)) + { + FieldTypes mergedChoices = choices | _parentMatch.Pattern.Choices; + + // If the parent pattern didn't match anything, look deeper. + if (_parentMatch.FieldsMatched.Count == 0) + { + mergedChoices = _parentMatch.IncludeChoicesFromParentMatch(mergedChoices); + } + + return mergedChoices; + } + } + + return choices; + } + + /// + /// Returns a new state for match failure because the resource field chain contains more fields than expected. + /// + public MatchState FailureForTooMuchInput() + { + FieldTypes parentChoices = IncludeChoicesFromParentMatch(FieldTypes.None); + int position = GetAbsolutePosition(true); + var error = MatchError.CreateForTooMuchInput(position, _parentMatch?.ResourceType, parentChoices); + + return Failure(error); + } + + private MatchState Failure(MatchError error) + { + return new MatchState(Pattern, ResourceType, FieldsMatched, FieldsRemaining, error, _parentMatch); + } + + private int GetAbsolutePosition(bool hasLeadingDot) + { + int length = 0; + MatchState? currentState = this; + + while (currentState != null) + { + length += currentState.FieldsMatched.Sum(field => field.PublicName.Length + 1); + currentState = currentState._parentMatch; + } + + length = length > 0 ? length - 1 : 0; + + if (length > 0 && hasLeadingDot) + { + length++; + } + + return length; + } + + public override string ToString() + { + var builder = new StringBuilder(); + + if (FieldsMatched.Count == 0 && FieldsRemaining == null && Pattern == null) + { + builder.Append("EMPTY"); + } + else + { + builder.Append(Error == null ? "SUCCESS: " : "FAILED: "); + builder.Append("Matched '"); + builder.Append(string.Join('.', FieldsMatched)); + builder.Append("' against '"); + builder.Append(Pattern?.WithoutNext()); + builder.Append("' with remaining '"); + builder.Append(string.Join('.', FieldsRemaining.ToEnumerable())); + builder.Append('\''); + } + + if (_parentMatch != null) + { + builder.Append(" -> "); + builder.Append(_parentMatch); + } + + return builder.ToString(); + } + + public IReadOnlyList GetAllFieldsMatched() + { + Stack> matchStack = new(); + MatchState? current = this; + + while (current != null) + { + matchStack.Push(current.FieldsMatched); + current = current._parentMatch; + } + + List fields = []; + + while (matchStack.Count > 0) + { + IImmutableList matches = matchStack.Pop(); + fields.AddRange(matches); + } + + return fields.AsReadOnly(); + } + + private static void AssertIsSuccess(MatchState state) + { + if (state.Error != null) + { + throw new InvalidOperationException($"Internal error: Expected successful match, but found error: {state.Error}"); + } + } + + private void AssertHasResourceType() + { + if (ResourceType == null) + { + throw new InvalidOperationException("Internal error: Resource type is unavailable."); + } + } + + private void AssertHasPattern() + { + if (Pattern == null) + { + throw new InvalidOperationException("Internal error: Pattern chain is unavailable."); + } + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchTraceScope.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchTraceScope.cs new file mode 100644 index 0000000000..5f3dd5f713 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/MatchTraceScope.cs @@ -0,0 +1,163 @@ +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// Logs the pattern matching steps at level. +/// +internal sealed partial class MatchTraceScope : IDisposable +{ + private readonly FieldChainPattern? _pattern; + private readonly bool _isEnabled; + private readonly ILogger _logger; + private readonly int _indentDepth; + private MatchState? _endState; + + private MatchTraceScope(FieldChainPattern? pattern, bool isEnabled, ILogger logger, int indentDepth) + { + _pattern = pattern; + _isEnabled = isEnabled; + _logger = logger; + _indentDepth = indentDepth; + } + + public static MatchTraceScope CreateRoot(MatchState startState, ILogger logger) + { + ArgumentNullException.ThrowIfNull(startState); + ArgumentNullException.ThrowIfNull(logger); + + bool isEnabled = logger.IsEnabled(LogLevel.Trace); + var scope = new MatchTraceScope(startState.Pattern, isEnabled, logger, 0); + + if (isEnabled) + { + string fieldsRemaining = FormatFieldsRemaining(startState); + scope.LogMatchFirst(startState.Pattern, fieldsRemaining); + } + + return scope; + } + + public MatchTraceScope CreateChild(MatchState startState) + { + ArgumentNullException.ThrowIfNull(startState); + + int indentDepth = _indentDepth + 1; + FieldChainPattern? patternSegment = startState.Pattern?.WithoutNext(); + + if (_isEnabled) + { + string indent = GetIndentText(); + string fieldsRemaining = FormatFieldsRemaining(startState); + LogMatchNext(indent, patternSegment, fieldsRemaining); + } + + return new MatchTraceScope(patternSegment, _isEnabled, _logger, indentDepth); + } + + public void LogMatchResult(MatchState resultState) + { + ArgumentNullException.ThrowIfNull(resultState); + + if (_isEnabled) + { + string indent = GetIndentText(); + + if (resultState.Error == null) + { + string fieldsMatched = FormatFieldsMatched(resultState); + LogMatchSuccess(indent, _pattern, fieldsMatched); + } + else + { + List chain = [.. resultState.FieldsMatched.Select(attribute => attribute.PublicName)]; + + if (resultState.FieldsRemaining != null) + { + chain.Add(resultState.FieldsRemaining.Value); + } + + string chainText = string.Join('.', chain); + LogMatchFailed(indent, _pattern, chainText); + } + } + } + + public void LogBacktrackTo(MatchState backtrackState) + { + ArgumentNullException.ThrowIfNull(backtrackState); + + if (_isEnabled) + { + string indent = GetIndentText(); + string fieldsMatched = FormatFieldsMatched(backtrackState); + LogBacktrack(indent, fieldsMatched); + } + } + + public void SetResult(MatchState endState) + { + ArgumentNullException.ThrowIfNull(endState); + + _endState = endState; + } + + public void Dispose() + { + if (_endState == null) + { + throw new InvalidOperationException("Internal error: End state must be set before leaving trace scope."); + } + + if (_isEnabled) + { + string indent = GetIndentText(); + + if (_endState.Error == null) + { + LogCompletionSuccess(indent); + } + else + { + LogCompletionFailure(indent); + } + } + } + + private static string FormatFieldsRemaining(MatchState state) + { + return string.Join('.', state.FieldsRemaining.ToEnumerable()); + } + + private static string FormatFieldsMatched(MatchState state) + { + return string.Join('.', state.FieldsMatched); + } + + private string GetIndentText() + { + return new string(' ', _indentDepth * 2); + } + + [LoggerMessage(Level = LogLevel.Trace, SkipEnabledCheck = true, Message = "Start matching pattern '{Pattern}' against the complete chain '{Chain}'.")] + private partial void LogMatchFirst(FieldChainPattern? pattern, string chain); + + [LoggerMessage(Level = LogLevel.Trace, SkipEnabledCheck = true, + Message = "{Indent}Start matching pattern '{Pattern}' against the remaining chain '{Chain}'.")] + private partial void LogMatchNext(string indent, FieldChainPattern? pattern, string chain); + + [LoggerMessage(Level = LogLevel.Trace, SkipEnabledCheck = true, Message = "{Indent}Match pattern '{Pattern}' against the chain '{Chain}': Success.")] + private partial void LogMatchSuccess(string indent, FieldChainPattern? pattern, string chain); + + [LoggerMessage(Level = LogLevel.Trace, SkipEnabledCheck = true, Message = "{Indent}Match pattern '{Pattern}' against the chain '{Chain}': Failed.")] + private partial void LogMatchFailed(string indent, FieldChainPattern? pattern, string chain); + + [LoggerMessage(Level = LogLevel.Trace, SkipEnabledCheck = true, Message = "{Indent}Backtracking to successful match against '{Chain}'.")] + private partial void LogBacktrack(string indent, string chain); + + [LoggerMessage(Level = LogLevel.Trace, SkipEnabledCheck = true, Message = "{Indent}Matching completed with success.")] + private partial void LogCompletionSuccess(string indent); + + [LoggerMessage(Level = LogLevel.Trace, SkipEnabledCheck = true, Message = "{Indent}Matching completed with failure.")] + private partial void LogCompletionFailure(string indent); +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternDescriptionFormatter.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternDescriptionFormatter.cs new file mode 100644 index 0000000000..bdd29e3200 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternDescriptionFormatter.cs @@ -0,0 +1,64 @@ +using System.Text; + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// Formats a chain of segments into a human-readable description. +/// +internal sealed class PatternDescriptionFormatter +{ + private readonly FieldChainPattern _pattern; + + public PatternDescriptionFormatter(FieldChainPattern pattern) + { + ArgumentNullException.ThrowIfNull(pattern); + + _pattern = pattern; + } + + public string Format() + { + FieldChainPattern? current = _pattern; + var builder = new StringBuilder(); + + do + { + WriteSeparator(builder); + WriteQuantifier(current.AtLeastOne, current.AtMostOne, builder); + WriteChoices(current, builder); + + current = current.Next; + } + while (current != null); + + return builder.ToString(); + } + + private static void WriteSeparator(StringBuilder builder) + { + if (builder.Length > 0) + { + builder.Append(", followed by "); + } + } + + private static void WriteQuantifier(bool atLeastOne, bool atMostOne, StringBuilder builder) + { + if (!atLeastOne) + { + builder.Append(atMostOne ? "an optional " : "zero or more "); + } + else if (!atMostOne) + { + builder.Append("one or more "); + } + } + + private static void WriteChoices(FieldChainPattern pattern, StringBuilder builder) + { + bool pluralize = !pattern.AtMostOne; + bool prefix = pattern is { AtLeastOne: true, AtMostOne: true }; + + pattern.Choices.WriteTo(builder, pluralize, prefix); + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternFormatException.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternFormatException.cs new file mode 100644 index 0000000000..3a607c435f --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternFormatException.cs @@ -0,0 +1,21 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// The exception that is thrown when the format of a is invalid. +/// +[PublicAPI] +public sealed class PatternFormatException(string pattern, int position, string message) + : FormatException(message) +{ + /// + /// Gets the text of the invalid pattern. + /// + public string Pattern { get; } = pattern; + + /// + /// Gets the zero-based error position in , or at its end. + /// + public int Position { get; } = position; +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatchResult.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatchResult.cs new file mode 100644 index 0000000000..6b9a1d1075 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatchResult.cs @@ -0,0 +1,63 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// Represents the result of matching a dot-separated resource field chain against a pattern. +/// +[PublicAPI] +public sealed class PatternMatchResult +{ + /// + /// Indicates whether the match succeeded. + /// + public bool IsSuccess { get; } + + /// + /// The resolved field chain, when is true. + /// + /// + /// The chain may be empty, if the pattern allows for that. + /// + public IReadOnlyList FieldChain { get; } + + /// + /// Gets the match failure message, when is false. + /// + public string FailureMessage { get; } + + /// + /// Gets the zero-based position in the resource field chain, or at its end, where the match failure occurred. + /// + public int FailurePosition { get; } + + /// + /// Indicates whether the match failed due to an invalid field chain, irrespective of greedy matching. + /// + public bool IsFieldChainError { get; } + + private PatternMatchResult(bool isSuccess, IReadOnlyList fieldChain, string failureMessage, int failurePosition, + bool isFieldChainError) + { + IsSuccess = isSuccess; + FieldChain = fieldChain; + FailureMessage = failureMessage; + FailurePosition = failurePosition; + IsFieldChainError = isFieldChainError; + } + + internal static PatternMatchResult CreateForSuccess(IReadOnlyList fieldChain) + { + ArgumentNullException.ThrowIfNull(fieldChain); + + return new PatternMatchResult(true, fieldChain, string.Empty, -1, false); + } + + internal static PatternMatchResult CreateForFailure(MatchError error) + { + ArgumentNullException.ThrowIfNull(error); + + return new PatternMatchResult(false, Array.Empty(), error.Message, error.Position, error.IsFieldChainError); + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs new file mode 100644 index 0000000000..9a86542533 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternMatcher.cs @@ -0,0 +1,241 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// Matches a resource field chain against a pattern. +/// +internal sealed class PatternMatcher +{ + private readonly FieldChainPattern _pattern; + private readonly ILogger _logger; + private readonly bool _allowDerivedTypes; + + public PatternMatcher(FieldChainPattern pattern, FieldChainPatternMatchOptions options, ILogger logger) + { + ArgumentNullException.ThrowIfNull(pattern); + ArgumentNullException.ThrowIfNull(logger); + + _pattern = pattern; + _logger = logger; + _allowDerivedTypes = options.HasFlag(FieldChainPatternMatchOptions.AllowDerivedTypes); + } + + public PatternMatchResult Match(string fieldChain, ResourceType resourceType) + { + ArgumentNullException.ThrowIfNull(fieldChain); + ArgumentNullException.ThrowIfNull(resourceType); + + var startState = MatchState.Create(_pattern, fieldChain, resourceType); + + if (startState.Error != null) + { + return PatternMatchResult.CreateForFailure(startState.Error); + } + + using var traceScope = MatchTraceScope.CreateRoot(startState, _logger); + + MatchState endState = MatchPattern(startState, traceScope); + traceScope.SetResult(endState); + + return endState.Error == null + ? PatternMatchResult.CreateForSuccess(endState.GetAllFieldsMatched()) + : PatternMatchResult.CreateForFailure(endState.Error); + } + + /// + /// Matches the first segment in against . + /// + private MatchState MatchPattern(MatchState state, MatchTraceScope parentTraceScope) + { + AssertIsSuccess(state); + + FieldChainPattern? patternSegment = state.Pattern; + using MatchTraceScope traceScope = parentTraceScope.CreateChild(state); + + if (patternSegment == null) + { + MatchState endState = state.FieldsRemaining == null ? state : state.FailureForTooMuchInput(); + traceScope.LogMatchResult(endState); + traceScope.SetResult(endState); + + return endState; + } + + // Build a stack of successful matches against this pattern segment, incrementally trying to match more fields. + Stack backtrackStack = new(); + + if (!patternSegment.AtLeastOne) + { + // Also include match against empty chain, which always succeeds. + traceScope.LogMatchResult(state); + backtrackStack.Push(state); + } + + MatchState greedyState = state; + + do + { + if (!patternSegment.AtLeastOne && greedyState.FieldsRemaining == null) + { + // Already added above. + continue; + } + + greedyState = MatchField(greedyState); + traceScope.LogMatchResult(greedyState); + + if (greedyState.Error == null) + { + backtrackStack.Push(greedyState); + } + } + while (!patternSegment.AtMostOne && greedyState is { FieldsRemaining: not null, Error: null }); + + // The best error to return is the failure from matching the remaining pattern chain at the most-greedy successful match. + // If matching against the remaining pattern chains doesn't fail, use the most-greedy failure itself. + MatchState bestErrorEndState = greedyState; + + // Evaluate the stacked matches (greedy, so longest first) against the remaining pattern chain. + while (backtrackStack.Count > 0) + { + MatchState backtrackState = backtrackStack.Pop(); + + if (backtrackState != greedyState) + { + // If we're at to most-recent match, and it succeeded, then we're not really backtracking. + traceScope.LogBacktrackTo(backtrackState); + } + + // Match the remaining pattern chain against the remaining field chain. + MatchState endState = MatchPattern(backtrackState.SuccessMoveToNextPattern(), traceScope); + + if (endState.Error == null) + { + traceScope.SetResult(endState); + return endState; + } + + if (bestErrorEndState == greedyState) + { + bestErrorEndState = endState; + } + } + + if (greedyState.Error?.IsFieldChainError == true) + { + // There was an error in the field chain itself, irrespective of backtracking. + // It is therefore more relevant to report over any other error. + bestErrorEndState = greedyState; + } + + traceScope.SetResult(bestErrorEndState); + return bestErrorEndState; + } + + private static void AssertIsSuccess(MatchState state) + { + if (state.Error != null) + { + throw new InvalidOperationException($"Internal error: Expected successful match, but found error: {state.Error}"); + } + } + + /// + /// Matches the first remaining field against the set of choices in the current pattern segment. + /// + private MatchState MatchField(MatchState state) + { + FieldTypes choices = state.Pattern!.Choices; + ResourceFieldAttribute? chosenField = null; + + if (state.FieldsRemaining != null) + { + string publicName = state.FieldsRemaining.Value; + + HashSet fields = LookupFields(state.ResourceType, publicName); + + if (fields.Count == 0) + { + return state.FailureForUnknownField(publicName, _allowDerivedTypes); + } + + chosenField = fields.First(); + + fields.RemoveWhere(field => !IsTypeMatch(field, choices)); + + if (fields.Count == 1) + { + return state.SuccessMoveForwardOneField(fields.First()); + } + + if (fields.Count > 1) + { + return state.FailureForMultipleDerivedTypes(publicName); + } + } + + FieldTypes chosenFieldType = GetFieldType(chosenField); + return state.FailureForFieldTypeMismatch(choices, chosenFieldType); + } + + /// + /// Lookup the specified field in the resource graph. + /// + private HashSet LookupFields(ResourceType? resourceType, string publicName) + { + HashSet fields = []; + + if (resourceType != null) + { + if (_allowDerivedTypes) + { + IReadOnlySet attributes = resourceType.GetAttributesInTypeOrDerived(publicName); + fields.UnionWith(attributes); + + IReadOnlySet relationships = resourceType.GetRelationshipsInTypeOrDerived(publicName); + fields.UnionWith(relationships); + } + else + { + AttrAttribute? attribute = resourceType.FindAttributeByPublicName(publicName); + + if (attribute != null) + { + fields.Add(attribute); + } + + RelationshipAttribute? relationship = resourceType.FindRelationshipByPublicName(publicName); + + if (relationship != null) + { + fields.Add(relationship); + } + } + } + + return fields; + } + + private static bool IsTypeMatch(ResourceFieldAttribute field, FieldTypes types) + { + FieldTypes chosenType = GetFieldType(field); + + return (types & chosenType) != FieldTypes.None; + } + + private static FieldTypes GetFieldType(ResourceFieldAttribute? field) + { + return field switch + { + HasManyAttribute => FieldTypes.ToManyRelationship, + HasOneAttribute => FieldTypes.ToOneRelationship, + RelationshipAttribute => FieldTypes.Relationship, + AttrAttribute => FieldTypes.Attribute, + null => FieldTypes.None, + _ => FieldTypes.Field + }; + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternParser.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternParser.cs new file mode 100644 index 0000000000..c6e05fdd4f --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternParser.cs @@ -0,0 +1,186 @@ +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// Parses a field chain pattern from text into a chain of segments. +/// +internal sealed class PatternParser +{ + private static readonly Dictionary CharToTokenTable = new() + { + ['?'] = Token.QuestionMark, + ['+'] = Token.Plus, + ['*'] = Token.Asterisk, + ['['] = Token.BracketOpen, + [']'] = Token.BracketClose, + ['M'] = Token.ToManyRelationship, + ['O'] = Token.ToOneRelationship, + ['R'] = Token.Relationship, + ['A'] = Token.Attribute, + ['F'] = Token.Field + }; + + private static readonly Dictionary TokenToFieldTypeTable = new() + { + [Token.ToManyRelationship] = FieldTypes.ToManyRelationship, + [Token.ToOneRelationship] = FieldTypes.ToOneRelationship, + [Token.Relationship] = FieldTypes.Relationship, + [Token.Attribute] = FieldTypes.Attribute, + [Token.Field] = FieldTypes.Field + }; + + private static readonly HashSet QuantifierTokens = + [ + Token.QuestionMark, + Token.Plus, + Token.Asterisk + ]; + + private string _source = null!; + private Queue _tokenQueue = null!; + private int _position; + + public FieldChainPattern Parse(string source) + { + ArgumentNullException.ThrowIfNull(source); + + _source = source; + EnqueueTokens(); + + _position = 0; + FieldChainPattern? pattern = TryParsePatternChain(); + + if (pattern == null) + { + throw new PatternFormatException(_source, _position, "Pattern is empty."); + } + + return pattern; + } + + private void EnqueueTokens() + { + _tokenQueue = new Queue(); + _position = 0; + + foreach (char character in _source) + { + if (CharToTokenTable.TryGetValue(character, out Token token)) + { + _tokenQueue.Enqueue(token); + } + else + { + throw new PatternFormatException(_source, _position, $"Unknown token '{character}'."); + } + + _position++; + } + } + + private FieldChainPattern? TryParsePatternChain() + { + if (_tokenQueue.Count == 0) + { + return null; + } + + FieldTypes choices = ParseTypeOrSet(); + (bool atLeastOne, bool atMostOne) = ParseQuantifier(); + FieldChainPattern? next = TryParsePatternChain(); + + return new FieldChainPattern(choices, atLeastOne, atMostOne, next); + } + + private FieldTypes ParseTypeOrSet() + { + bool isChoiceSet = TryEatToken(static token => token == Token.BracketOpen) != null; + FieldTypes choices = EatFieldType(isChoiceSet ? "Field type expected." : "Field type or [ expected."); + + if (isChoiceSet) + { + FieldTypes? extraChoice; + + while ((extraChoice = TryEatFieldType()) != null) + { + choices |= extraChoice.Value; + } + + EatToken(static token => token == Token.BracketClose, "Field type or ] expected."); + } + + return choices; + } + + private (bool atLeastOne, bool atMostOne) ParseQuantifier() + { + Token? quantifier = TryEatToken(static token => QuantifierTokens.Contains(token)); + + return quantifier switch + { + Token.QuestionMark => (false, true), + Token.Plus => (true, false), + Token.Asterisk => (false, false), + _ => (true, true) + }; + } + + private FieldTypes EatFieldType(string errorMessage) + { + FieldTypes? fieldType = TryEatFieldType(); + + if (fieldType != null) + { + return fieldType.Value; + } + + throw new PatternFormatException(_source, _position, errorMessage); + } + + private FieldTypes? TryEatFieldType() + { + Token? token = TryEatToken(static token => TokenToFieldTypeTable.ContainsKey(token)); + + if (token != null) + { + return TokenToFieldTypeTable[token.Value]; + } + + return null; + } + + private void EatToken(Predicate condition, string errorMessage) + { + Token? token = TryEatToken(condition); + + if (token == null) + { + throw new PatternFormatException(_source, _position, errorMessage); + } + } + + private Token? TryEatToken(Predicate condition) + { + if (_tokenQueue.TryPeek(out Token nextToken) && condition(nextToken)) + { + _tokenQueue.Dequeue(); + _position++; + return nextToken; + } + + return null; + } + + private enum Token + { + QuestionMark, + Plus, + Asterisk, + BracketOpen, + BracketClose, + ToManyRelationship, + ToOneRelationship, + Relationship, + Attribute, + Field + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternTextFormatter.cs b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternTextFormatter.cs new file mode 100644 index 0000000000..2fec95b900 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FieldChains/PatternTextFormatter.cs @@ -0,0 +1,85 @@ +using System.Text; + +namespace JsonApiDotNetCore.QueryStrings.FieldChains; + +/// +/// Formats a chain of segments into text. +/// +internal sealed class PatternTextFormatter +{ + private readonly FieldChainPattern _pattern; + + public PatternTextFormatter(FieldChainPattern pattern) + { + ArgumentNullException.ThrowIfNull(pattern); + + _pattern = pattern; + } + + public string Format() + { + FieldChainPattern? current = _pattern; + var builder = new StringBuilder(); + + do + { + WriteChoices(current.Choices, builder); + WriteQuantifier(current.AtLeastOne, current.AtMostOne, builder); + + current = current.Next; + } + while (current != null); + + return builder.ToString(); + } + + private static void WriteChoices(FieldTypes types, StringBuilder builder) + { + int startOffset = builder.Length; + + if (types.HasFlag(FieldTypes.ToManyRelationship) && !types.HasFlag(FieldTypes.Relationship)) + { + builder.Append('M'); + } + + if (types.HasFlag(FieldTypes.ToOneRelationship) && !types.HasFlag(FieldTypes.Relationship)) + { + builder.Append('O'); + } + + if (types.HasFlag(FieldTypes.Attribute) && !types.HasFlag(FieldTypes.Relationship)) + { + builder.Append('A'); + } + + if (types.HasFlag(FieldTypes.Relationship) && !types.HasFlag(FieldTypes.Field)) + { + builder.Append('R'); + } + + if (types.HasFlag(FieldTypes.Field)) + { + builder.Append('F'); + } + + int charCount = builder.Length - startOffset; + + if (charCount > 1) + { + builder.Insert(startOffset, '['); + builder.Append(']'); + } + } + + private static void WriteQuantifier(bool atLeastOne, bool atMostOne, StringBuilder builder) + { + if (!atLeastOne) + { + builder.Append(atMostOne ? '?' : '*'); + } + else if (!atMostOne) + { + builder.Append('+'); + } + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/FilterQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/FilterQueryStringParameterReader.cs new file mode 100644 index 0000000000..6b85273ddb --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/FilterQueryStringParameterReader.cs @@ -0,0 +1,178 @@ +using System.Collections.Immutable; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings.FieldChains; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCore.QueryStrings; + +/// +[PublicAPI] +public class FilterQueryStringParameterReader : QueryStringParameterReader, IFilterQueryStringParameterReader +{ + private static readonly LegacyFilterNotationConverter LegacyConverter = new(); + + private readonly IJsonApiOptions _options; + private readonly IQueryStringParameterScopeParser _scopeParser; + private readonly IFilterParser _filterParser; + private readonly ImmutableArray.Builder _filtersInGlobalScope = ImmutableArray.CreateBuilder(); + private readonly Dictionary.Builder> _filtersPerScope = []; + + public bool AllowEmptyValue => false; + + public FilterQueryStringParameterReader(IQueryStringParameterScopeParser scopeParser, IFilterParser filterParser, IJsonApiRequest request, + IResourceGraph resourceGraph, IJsonApiOptions options) + : base(request, resourceGraph) + { + ArgumentNullException.ThrowIfNull(scopeParser); + ArgumentNullException.ThrowIfNull(filterParser); + ArgumentNullException.ThrowIfNull(options); + + _options = options; + _scopeParser = scopeParser; + _filterParser = filterParser; + } + + /// + public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) + { + ArgumentNullException.ThrowIfNull(disableQueryStringAttribute); + + return !IsAtomicOperationsRequest && !disableQueryStringAttribute.ContainsParameter(JsonApiQueryStringParameters.Filter); + } + + /// + public virtual bool CanRead(string parameterName) + { + ArgumentException.ThrowIfNullOrEmpty(parameterName); + + bool isNested = parameterName.StartsWith("filter[", StringComparison.Ordinal) && parameterName.EndsWith(']'); + return parameterName == "filter" || isNested; + } + + /// + public virtual void Read(string parameterName, StringValues parameterValue) + { + foreach (string value in parameterValue.SelectMany(ExtractParameterValue)) + { + ReadSingleValue(parameterName, value); + } + } + + private IEnumerable ExtractParameterValue(string? parameterValue) + { + if (parameterValue != null) + { + if (_options.EnableLegacyFilterNotation) + { + foreach (string condition in LegacyConverter.ExtractConditions(parameterValue)) + { + yield return condition; + } + } + else + { + yield return parameterValue; + } + } + } + + private void ReadSingleValue(string parameterName, string parameterValue) + { + bool parameterNameIsValid = false; + + try + { + string name = parameterName; + string value = parameterValue; + + if (_options.EnableLegacyFilterNotation) + { + (name, value) = LegacyConverter.Convert(name, value); + } + + ResourceFieldChainExpression? scope = GetScope(name); + parameterNameIsValid = true; + + FilterExpression filter = GetFilter(value, scope); + StoreFilterInScope(filter, scope); + } + catch (QueryParseException exception) + { + string specificMessage = _options.EnableLegacyFilterNotation + ? exception.Message + : exception.GetMessageWithPosition(parameterNameIsValid ? parameterValue : parameterName); + + throw new InvalidQueryStringParameterException(parameterName, "The specified filter is invalid.", specificMessage, exception); + } + } + + private ResourceFieldChainExpression? GetScope(string parameterName) + { + QueryStringParameterScopeExpression parameterScope = _scopeParser.Parse(parameterName, RequestResourceType, + BuiltInPatterns.RelationshipChainEndingInToMany, FieldChainPatternMatchOptions.None); + + if (parameterScope.Scope == null) + { + AssertIsCollectionRequest(); + } + + return parameterScope.Scope; + } + + private FilterExpression GetFilter(string parameterValue, ResourceFieldChainExpression? scope) + { + ResourceType resourceTypeInScope = GetResourceTypeForScope(scope); + return _filterParser.Parse(parameterValue, resourceTypeInScope); + } + + private void StoreFilterInScope(FilterExpression filter, ResourceFieldChainExpression? scope) + { + if (scope == null) + { + _filtersInGlobalScope.Add(filter); + } + else + { + if (!_filtersPerScope.TryGetValue(scope, out ImmutableArray.Builder? builder)) + { + builder = ImmutableArray.CreateBuilder(); + _filtersPerScope[scope] = builder; + } + + builder.Add(filter); + } + } + + /// + public virtual IReadOnlyCollection GetConstraints() + { + return EnumerateFiltersInScopes().ToArray().AsReadOnly(); + } + + private IEnumerable EnumerateFiltersInScopes() + { + if (_filtersInGlobalScope.Count > 0) + { + FilterExpression filter = MergeFilters(_filtersInGlobalScope.ToImmutable()); + yield return new ExpressionInScope(null, filter); + } + + foreach ((ResourceFieldChainExpression scope, ImmutableArray.Builder filtersBuilder) in _filtersPerScope) + { + FilterExpression filter = MergeFilters(filtersBuilder.ToImmutable()); + yield return new ExpressionInScope(scope, filter); + } + } + + private static FilterExpression MergeFilters(IImmutableList filters) + { + return filters.Count > 1 ? new LogicalExpression(LogicalOperator.Or, filters) : filters[0]; + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/IFilterQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IFilterQueryStringParameterReader.cs index b9a7fb8c6b..4fdefe2124 100644 --- a/src/JsonApiDotNetCore/QueryStrings/IFilterQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IFilterQueryStringParameterReader.cs @@ -7,6 +7,4 @@ namespace JsonApiDotNetCore.QueryStrings; /// Reads the 'filter' query string parameter and produces a set of query constraints from it. /// [PublicAPI] -public interface IFilterQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider -{ -} +public interface IFilterQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider; diff --git a/src/JsonApiDotNetCore/QueryStrings/IIncludeQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IIncludeQueryStringParameterReader.cs index 1993d249fc..822df2ee68 100644 --- a/src/JsonApiDotNetCore/QueryStrings/IIncludeQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IIncludeQueryStringParameterReader.cs @@ -7,6 +7,4 @@ namespace JsonApiDotNetCore.QueryStrings; /// Reads the 'include' query string parameter and produces a set of query constraints from it. /// [PublicAPI] -public interface IIncludeQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider -{ -} +public interface IIncludeQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider; diff --git a/src/JsonApiDotNetCore/QueryStrings/IPaginationQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IPaginationQueryStringParameterReader.cs index 198bff6ff8..56141e5615 100644 --- a/src/JsonApiDotNetCore/QueryStrings/IPaginationQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IPaginationQueryStringParameterReader.cs @@ -7,6 +7,4 @@ namespace JsonApiDotNetCore.QueryStrings; /// Reads the 'page' query string parameter and produces a set of query constraints from it. /// [PublicAPI] -public interface IPaginationQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider -{ -} +public interface IPaginationQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider; diff --git a/src/JsonApiDotNetCore/QueryStrings/IResourceDefinitionQueryableParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IResourceDefinitionQueryableParameterReader.cs index 9ef114e4b3..965eb2d884 100644 --- a/src/JsonApiDotNetCore/QueryStrings/IResourceDefinitionQueryableParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IResourceDefinitionQueryableParameterReader.cs @@ -9,6 +9,4 @@ namespace JsonApiDotNetCore.QueryStrings; /// query constraints from it. /// [PublicAPI] -public interface IResourceDefinitionQueryableParameterReader : IQueryStringParameterReader, IQueryConstraintProvider -{ -} +public interface IResourceDefinitionQueryableParameterReader : IQueryStringParameterReader, IQueryConstraintProvider; diff --git a/src/JsonApiDotNetCore/QueryStrings/ISortQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/ISortQueryStringParameterReader.cs index 5cb221a399..763d1a67f1 100644 --- a/src/JsonApiDotNetCore/QueryStrings/ISortQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/ISortQueryStringParameterReader.cs @@ -7,6 +7,4 @@ namespace JsonApiDotNetCore.QueryStrings; /// Reads the 'sort' query string parameter and produces a set of query constraints from it. /// [PublicAPI] -public interface ISortQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider -{ -} +public interface ISortQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider; diff --git a/src/JsonApiDotNetCore/QueryStrings/ISparseFieldSetQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/ISparseFieldSetQueryStringParameterReader.cs index e943121ecc..1f0bdaf90f 100644 --- a/src/JsonApiDotNetCore/QueryStrings/ISparseFieldSetQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/ISparseFieldSetQueryStringParameterReader.cs @@ -7,6 +7,4 @@ namespace JsonApiDotNetCore.QueryStrings; /// Reads the 'fields' query string parameter and produces a set of query constraints from it. /// [PublicAPI] -public interface ISparseFieldSetQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider -{ -} +public interface ISparseFieldSetQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider; diff --git a/src/JsonApiDotNetCore/QueryStrings/IncludeQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IncludeQueryStringParameterReader.cs new file mode 100644 index 0000000000..55ef25779f --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/IncludeQueryStringParameterReader.cs @@ -0,0 +1,75 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCore.QueryStrings; + +/// +[PublicAPI] +public class IncludeQueryStringParameterReader : QueryStringParameterReader, IIncludeQueryStringParameterReader +{ + private readonly IIncludeParser _includeParser; + + private IncludeExpression? _includeExpression; + + public bool AllowEmptyValue => true; + + public IncludeQueryStringParameterReader(IIncludeParser includeParser, IJsonApiRequest request, IResourceGraph resourceGraph) + : base(request, resourceGraph) + { + ArgumentNullException.ThrowIfNull(includeParser); + + _includeParser = includeParser; + } + + /// + public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) + { + ArgumentNullException.ThrowIfNull(disableQueryStringAttribute); + + return !IsAtomicOperationsRequest && !disableQueryStringAttribute.ContainsParameter(JsonApiQueryStringParameters.Include); + } + + /// + public virtual bool CanRead(string parameterName) + { + ArgumentException.ThrowIfNullOrEmpty(parameterName); + + return parameterName == "include"; + } + + /// + public virtual void Read(string parameterName, StringValues parameterValue) + { + try + { + _includeExpression = GetInclude(parameterValue.ToString()); + } + catch (QueryParseException exception) + { + string specificMessage = exception.GetMessageWithPosition(parameterValue.ToString()); + throw new InvalidQueryStringParameterException(parameterName, "The specified include is invalid.", specificMessage, exception); + } + } + + private IncludeExpression GetInclude(string parameterValue) + { + return _includeParser.Parse(parameterValue, RequestResourceType); + } + + /// + public virtual IReadOnlyCollection GetConstraints() + { + ExpressionInScope expressionInScope = _includeExpression != null + ? new ExpressionInScope(null, _includeExpression) + : new ExpressionInScope(null, IncludeExpression.Empty); + + return [expressionInScope]; + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs deleted file mode 100644 index 4fcd3e63d9..0000000000 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs +++ /dev/null @@ -1,176 +0,0 @@ -using System.Collections.Immutable; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers.Annotations; -using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal.Parsing; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using Microsoft.Extensions.Primitives; - -namespace JsonApiDotNetCore.QueryStrings.Internal; - -[PublicAPI] -public class FilterQueryStringParameterReader : QueryStringParameterReader, IFilterQueryStringParameterReader -{ - private static readonly LegacyFilterNotationConverter LegacyConverter = new(); - - private readonly IJsonApiOptions _options; - private readonly QueryStringParameterScopeParser _scopeParser; - private readonly FilterParser _filterParser; - private readonly ImmutableArray.Builder _filtersInGlobalScope = ImmutableArray.CreateBuilder(); - private readonly Dictionary.Builder> _filtersPerScope = new(); - - private string? _lastParameterName; - - public bool AllowEmptyValue => false; - - public FilterQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph, IResourceFactory resourceFactory, IJsonApiOptions options) - : base(request, resourceGraph) - { - ArgumentGuard.NotNull(options, nameof(options)); - - _options = options; - _scopeParser = new QueryStringParameterScopeParser(FieldChainRequirements.EndsInToMany); - _filterParser = new FilterParser(resourceFactory, ValidateSingleField); - } - - protected void ValidateSingleField(ResourceFieldAttribute field, ResourceType resourceType, string path) - { - if (field is AttrAttribute attribute && !attribute.Capabilities.HasFlag(AttrCapabilities.AllowFilter)) - { - throw new InvalidQueryStringParameterException(_lastParameterName!, "Filtering on the requested attribute is not allowed.", - $"Filtering on attribute '{attribute.PublicName}' is not allowed."); - } - } - - /// - public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) - { - ArgumentGuard.NotNull(disableQueryStringAttribute, nameof(disableQueryStringAttribute)); - - return !IsAtomicOperationsRequest && !disableQueryStringAttribute.ContainsParameter(JsonApiQueryStringParameters.Filter); - } - - /// - public virtual bool CanRead(string parameterName) - { - ArgumentGuard.NotNullNorEmpty(parameterName, nameof(parameterName)); - - bool isNested = parameterName.StartsWith("filter[", StringComparison.Ordinal) && parameterName.EndsWith("]", StringComparison.Ordinal); - return parameterName == "filter" || isNested; - } - - /// - public virtual void Read(string parameterName, StringValues parameterValue) - { - _lastParameterName = parameterName; - - foreach (string value in parameterValue.SelectMany(ExtractParameterValue)) - { - ReadSingleValue(parameterName, value); - } - } - - private IEnumerable ExtractParameterValue(string parameterValue) - { - if (_options.EnableLegacyFilterNotation) - { - foreach (string condition in LegacyConverter.ExtractConditions(parameterValue)) - { - yield return condition; - } - } - else - { - yield return parameterValue; - } - } - - private void ReadSingleValue(string parameterName, string parameterValue) - { - try - { - string name = parameterName; - string value = parameterValue; - - if (_options.EnableLegacyFilterNotation) - { - (name, value) = LegacyConverter.Convert(name, value); - } - - ResourceFieldChainExpression? scope = GetScope(name); - FilterExpression filter = GetFilter(value, scope); - - StoreFilterInScope(filter, scope); - } - catch (QueryParseException exception) - { - throw new InvalidQueryStringParameterException(_lastParameterName!, "The specified filter is invalid.", exception.Message, exception); - } - } - - private ResourceFieldChainExpression? GetScope(string parameterName) - { - QueryStringParameterScopeExpression parameterScope = _scopeParser.Parse(parameterName, RequestResourceType); - - if (parameterScope.Scope == null) - { - AssertIsCollectionRequest(); - } - - return parameterScope.Scope; - } - - private FilterExpression GetFilter(string parameterValue, ResourceFieldChainExpression? scope) - { - ResourceType resourceTypeInScope = GetResourceTypeForScope(scope); - return _filterParser.Parse(parameterValue, resourceTypeInScope); - } - - private void StoreFilterInScope(FilterExpression filter, ResourceFieldChainExpression? scope) - { - if (scope == null) - { - _filtersInGlobalScope.Add(filter); - } - else - { - if (!_filtersPerScope.ContainsKey(scope)) - { - _filtersPerScope[scope] = ImmutableArray.CreateBuilder(); - } - - _filtersPerScope[scope].Add(filter); - } - } - - /// - public virtual IReadOnlyCollection GetConstraints() - { - return EnumerateFiltersInScopes().ToArray(); - } - - private IEnumerable EnumerateFiltersInScopes() - { - if (_filtersInGlobalScope.Any()) - { - FilterExpression filter = MergeFilters(_filtersInGlobalScope.ToImmutable()); - yield return new ExpressionInScope(null, filter); - } - - foreach ((ResourceFieldChainExpression scope, ImmutableArray.Builder filtersBuilder) in _filtersPerScope) - { - FilterExpression filter = MergeFilters(filtersBuilder.ToImmutable()); - yield return new ExpressionInScope(scope, filter); - } - } - - private static FilterExpression MergeFilters(IImmutableList filters) - { - return filters.Count > 1 ? new LogicalExpression(LogicalOperator.Or, filters) : filters.First(); - } -} diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs deleted file mode 100644 index 6c8bfa2934..0000000000 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs +++ /dev/null @@ -1,73 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers.Annotations; -using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal.Parsing; -using Microsoft.Extensions.Primitives; - -namespace JsonApiDotNetCore.QueryStrings.Internal; - -[PublicAPI] -public class IncludeQueryStringParameterReader : QueryStringParameterReader, IIncludeQueryStringParameterReader -{ - private readonly IJsonApiOptions _options; - private readonly IncludeParser _includeParser; - - private IncludeExpression? _includeExpression; - - public bool AllowEmptyValue => false; - - public IncludeQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph, IJsonApiOptions options) - : base(request, resourceGraph) - { - ArgumentGuard.NotNull(options, nameof(options)); - - _options = options; - _includeParser = new IncludeParser(); - } - - /// - public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) - { - ArgumentGuard.NotNull(disableQueryStringAttribute, nameof(disableQueryStringAttribute)); - - return !IsAtomicOperationsRequest && !disableQueryStringAttribute.ContainsParameter(JsonApiQueryStringParameters.Include); - } - - /// - public virtual bool CanRead(string parameterName) - { - return parameterName == "include"; - } - - /// - public virtual void Read(string parameterName, StringValues parameterValue) - { - try - { - _includeExpression = GetInclude(parameterValue); - } - catch (QueryParseException exception) - { - throw new InvalidQueryStringParameterException(parameterName, "The specified include is invalid.", exception.Message, exception); - } - } - - private IncludeExpression GetInclude(string parameterValue) - { - return _includeParser.Parse(parameterValue, RequestResourceType, _options.MaximumIncludeDepth); - } - - /// - public virtual IReadOnlyCollection GetConstraints() - { - ExpressionInScope expressionInScope = _includeExpression != null - ? new ExpressionInScope(null, _includeExpression) - : new ExpressionInScope(null, IncludeExpression.Empty); - - return expressionInScope.AsArray(); - } -} diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/LegacyFilterNotationConverter.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/LegacyFilterNotationConverter.cs deleted file mode 100644 index 68d4555e26..0000000000 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/LegacyFilterNotationConverter.cs +++ /dev/null @@ -1,138 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Internal.Parsing; - -namespace JsonApiDotNetCore.QueryStrings.Internal; - -[PublicAPI] -public sealed class LegacyFilterNotationConverter -{ - private const string ParameterNamePrefix = "filter["; - private const string ParameterNameSuffix = "]"; - private const string OutputParameterName = "filter"; - - private const string ExpressionPrefix = "expr:"; - private const string NotEqualsPrefix = "ne:"; - private const string InPrefix = "in:"; - private const string NotInPrefix = "nin:"; - - private static readonly Dictionary PrefixConversionTable = new() - { - ["eq:"] = Keywords.Equals, - ["lt:"] = Keywords.LessThan, - ["le:"] = Keywords.LessOrEqual, - ["gt:"] = Keywords.GreaterThan, - ["ge:"] = Keywords.GreaterOrEqual, - ["like:"] = Keywords.Contains - }; - - public IEnumerable ExtractConditions(string parameterValue) - { - ArgumentGuard.NotNullNorEmpty(parameterValue, nameof(parameterValue)); - - if (parameterValue.StartsWith(ExpressionPrefix, StringComparison.Ordinal) || parameterValue.StartsWith(InPrefix, StringComparison.Ordinal) || - parameterValue.StartsWith(NotInPrefix, StringComparison.Ordinal)) - { - yield return parameterValue; - } - else - { - foreach (string condition in parameterValue.Split(',')) - { - yield return condition; - } - } - } - - public (string parameterName, string parameterValue) Convert(string parameterName, string parameterValue) - { - ArgumentGuard.NotNullNorEmpty(parameterName, nameof(parameterName)); - ArgumentGuard.NotNullNorEmpty(parameterValue, nameof(parameterValue)); - - if (parameterValue.StartsWith(ExpressionPrefix, StringComparison.Ordinal)) - { - string expression = parameterValue[ExpressionPrefix.Length..]; - return (parameterName, expression); - } - - string attributeName = ExtractAttributeName(parameterName); - - foreach ((string prefix, string keyword) in PrefixConversionTable) - { - if (parameterValue.StartsWith(prefix, StringComparison.Ordinal)) - { - string value = parameterValue[prefix.Length..]; - string escapedValue = EscapeQuotes(value); - string expression = $"{keyword}({attributeName},'{escapedValue}')"; - - return (OutputParameterName, expression); - } - } - - if (parameterValue.StartsWith(NotEqualsPrefix, StringComparison.Ordinal)) - { - string value = parameterValue[NotEqualsPrefix.Length..]; - string escapedValue = EscapeQuotes(value); - string expression = $"{Keywords.Not}({Keywords.Equals}({attributeName},'{escapedValue}'))"; - - return (OutputParameterName, expression); - } - - if (parameterValue.StartsWith(InPrefix, StringComparison.Ordinal)) - { - string[] valueParts = parameterValue[InPrefix.Length..].Split(","); - string valueList = $"'{string.Join("','", valueParts)}'"; - string expression = $"{Keywords.Any}({attributeName},{valueList})"; - - return (OutputParameterName, expression); - } - - if (parameterValue.StartsWith(NotInPrefix, StringComparison.Ordinal)) - { - string[] valueParts = parameterValue[NotInPrefix.Length..].Split(","); - string valueList = $"'{string.Join("','", valueParts)}'"; - string expression = $"{Keywords.Not}({Keywords.Any}({attributeName},{valueList}))"; - - return (OutputParameterName, expression); - } - - if (parameterValue == "isnull:") - { - string expression = $"{Keywords.Equals}({attributeName},null)"; - return (OutputParameterName, expression); - } - - if (parameterValue == "isnotnull:") - { - string expression = $"{Keywords.Not}({Keywords.Equals}({attributeName},null))"; - return (OutputParameterName, expression); - } - - { - string escapedValue = EscapeQuotes(parameterValue); - string expression = $"{Keywords.Equals}({attributeName},'{escapedValue}')"; - - return (OutputParameterName, expression); - } - } - - private static string ExtractAttributeName(string parameterName) - { - if (parameterName.StartsWith(ParameterNamePrefix, StringComparison.Ordinal) && parameterName.EndsWith(ParameterNameSuffix, StringComparison.Ordinal)) - { - string attributeName = parameterName.Substring(ParameterNamePrefix.Length, - parameterName.Length - ParameterNamePrefix.Length - ParameterNameSuffix.Length); - - if (attributeName.Length > 0) - { - return attributeName; - } - } - - throw new QueryParseException("Expected field name between brackets in filter parameter name."); - } - - private static string EscapeQuotes(string text) - { - return text.Replace("'", "''"); - } -} diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs deleted file mode 100644 index 743faee492..0000000000 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs +++ /dev/null @@ -1,208 +0,0 @@ -using System.Collections.Immutable; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers.Annotations; -using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal.Parsing; -using Microsoft.Extensions.Primitives; - -namespace JsonApiDotNetCore.QueryStrings.Internal; - -[PublicAPI] -public class PaginationQueryStringParameterReader : QueryStringParameterReader, IPaginationQueryStringParameterReader -{ - private const string PageSizeParameterName = "page[size]"; - private const string PageNumberParameterName = "page[number]"; - - private readonly IJsonApiOptions _options; - private readonly PaginationParser _paginationParser; - - private PaginationQueryStringValueExpression? _pageSizeConstraint; - private PaginationQueryStringValueExpression? _pageNumberConstraint; - - public bool AllowEmptyValue => false; - - public PaginationQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph, IJsonApiOptions options) - : base(request, resourceGraph) - { - ArgumentGuard.NotNull(options, nameof(options)); - - _options = options; - _paginationParser = new PaginationParser(); - } - - /// - public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) - { - ArgumentGuard.NotNull(disableQueryStringAttribute, nameof(disableQueryStringAttribute)); - - return !IsAtomicOperationsRequest && !disableQueryStringAttribute.ContainsParameter(JsonApiQueryStringParameters.Page); - } - - /// - public virtual bool CanRead(string parameterName) - { - return parameterName is PageSizeParameterName or PageNumberParameterName; - } - - /// - public virtual void Read(string parameterName, StringValues parameterValue) - { - try - { - PaginationQueryStringValueExpression constraint = GetPageConstraint(parameterValue); - - if (constraint.Elements.Any(element => element.Scope == null)) - { - AssertIsCollectionRequest(); - } - - if (parameterName == PageSizeParameterName) - { - ValidatePageSize(constraint); - _pageSizeConstraint = constraint; - } - else - { - ValidatePageNumber(constraint); - _pageNumberConstraint = constraint; - } - } - catch (QueryParseException exception) - { - throw new InvalidQueryStringParameterException(parameterName, "The specified paging is invalid.", exception.Message, exception); - } - } - - private PaginationQueryStringValueExpression GetPageConstraint(string parameterValue) - { - return _paginationParser.Parse(parameterValue, RequestResourceType); - } - - protected virtual void ValidatePageSize(PaginationQueryStringValueExpression constraint) - { - if (_options.MaximumPageSize != null) - { - if (constraint.Elements.Any(element => element.Value > _options.MaximumPageSize.Value)) - { - throw new QueryParseException($"Page size cannot be higher than {_options.MaximumPageSize}."); - } - - if (constraint.Elements.Any(element => element.Value == 0)) - { - throw new QueryParseException("Page size cannot be unconstrained."); - } - } - - if (constraint.Elements.Any(element => element.Value < 0)) - { - throw new QueryParseException("Page size cannot be negative."); - } - } - - [AssertionMethod] - protected virtual void ValidatePageNumber(PaginationQueryStringValueExpression constraint) - { - if (_options.MaximumPageNumber != null && constraint.Elements.Any(element => element.Value > _options.MaximumPageNumber.OneBasedValue)) - { - throw new QueryParseException($"Page number cannot be higher than {_options.MaximumPageNumber}."); - } - - if (constraint.Elements.Any(element => element.Value < 1)) - { - throw new QueryParseException("Page number cannot be negative or zero."); - } - } - - /// - public virtual IReadOnlyCollection GetConstraints() - { - var paginationState = new PaginationState(); - - foreach (PaginationElementQueryStringValueExpression element in _pageSizeConstraint?.Elements ?? - ImmutableArray.Empty) - { - MutablePaginationEntry entry = paginationState.ResolveEntryInScope(element.Scope); - entry.PageSize = element.Value == 0 ? null : new PageSize(element.Value); - entry.HasSetPageSize = true; - } - - foreach (PaginationElementQueryStringValueExpression element in _pageNumberConstraint?.Elements ?? - ImmutableArray.Empty) - { - MutablePaginationEntry entry = paginationState.ResolveEntryInScope(element.Scope); - entry.PageNumber = new PageNumber(element.Value); - } - - paginationState.ApplyOptions(_options); - - return paginationState.GetExpressionsInScope(); - } - - private sealed class PaginationState - { - private readonly MutablePaginationEntry _globalScope = new(); - private readonly Dictionary _nestedScopes = new(); - - public MutablePaginationEntry ResolveEntryInScope(ResourceFieldChainExpression? scope) - { - if (scope == null) - { - return _globalScope; - } - - if (!_nestedScopes.ContainsKey(scope)) - { - _nestedScopes.Add(scope, new MutablePaginationEntry()); - } - - return _nestedScopes[scope]; - } - - public void ApplyOptions(IJsonApiOptions options) - { - ApplyOptionsInEntry(_globalScope, options); - - foreach ((_, MutablePaginationEntry entry) in _nestedScopes) - { - ApplyOptionsInEntry(entry, options); - } - } - - private void ApplyOptionsInEntry(MutablePaginationEntry entry, IJsonApiOptions options) - { - if (!entry.HasSetPageSize) - { - entry.PageSize = options.DefaultPageSize; - } - - entry.PageNumber ??= PageNumber.ValueOne; - } - - public IReadOnlyCollection GetExpressionsInScope() - { - return EnumerateExpressionsInScope().ToArray(); - } - - private IEnumerable EnumerateExpressionsInScope() - { - yield return new ExpressionInScope(null, new PaginationExpression(_globalScope.PageNumber!, _globalScope.PageSize)); - - foreach ((ResourceFieldChainExpression scope, MutablePaginationEntry entry) in _nestedScopes) - { - yield return new ExpressionInScope(scope, new PaginationExpression(entry.PageNumber!, entry.PageSize)); - } - } - } - - private sealed class MutablePaginationEntry - { - public PageSize? PageSize { get; set; } - public bool HasSetPageSize { get; set; } - - public PageNumber? PageNumber { get; set; } - } -} diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringReader.cs deleted file mode 100644 index 78e6fe9e92..0000000000 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringReader.cs +++ /dev/null @@ -1,73 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers.Annotations; -using JsonApiDotNetCore.Diagnostics; -using JsonApiDotNetCore.Errors; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Primitives; - -namespace JsonApiDotNetCore.QueryStrings.Internal; - -/// -[PublicAPI] -public class QueryStringReader : IQueryStringReader -{ - private readonly IJsonApiOptions _options; - private readonly IRequestQueryStringAccessor _queryStringAccessor; - private readonly IEnumerable _parameterReaders; - private readonly ILogger _logger; - - public QueryStringReader(IJsonApiOptions options, IRequestQueryStringAccessor queryStringAccessor, - IEnumerable parameterReaders, ILoggerFactory loggerFactory) - { - ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(queryStringAccessor, nameof(queryStringAccessor)); - ArgumentGuard.NotNull(parameterReaders, nameof(parameterReaders)); - - _options = options; - _queryStringAccessor = queryStringAccessor; - _parameterReaders = parameterReaders; - _logger = loggerFactory.CreateLogger(); - } - - /// - public virtual void ReadAll(DisableQueryStringAttribute? disableQueryStringAttribute) - { - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Parse query string"); - - DisableQueryStringAttribute disableQueryStringAttributeNotNull = disableQueryStringAttribute ?? DisableQueryStringAttribute.Empty; - - foreach ((string parameterName, StringValues parameterValue) in _queryStringAccessor.Query) - { - IQueryStringParameterReader? reader = _parameterReaders.FirstOrDefault(nextReader => nextReader.CanRead(parameterName)); - - if (reader != null) - { - _logger.LogDebug($"Query string parameter '{parameterName}' with value '{parameterValue}' was accepted by {reader.GetType().Name}."); - - if (!reader.AllowEmptyValue && string.IsNullOrEmpty(parameterValue)) - { - throw new InvalidQueryStringParameterException(parameterName, "Missing query string parameter value.", - $"Missing value for '{parameterName}' query string parameter."); - } - - if (!reader.IsEnabled(disableQueryStringAttributeNotNull)) - { - throw new InvalidQueryStringParameterException(parameterName, - "Usage of one or more query string parameters is not allowed at the requested endpoint.", - $"The parameter '{parameterName}' cannot be used at this endpoint."); - } - - reader.Read(parameterName, parameterValue); - _logger.LogDebug($"Query string parameter '{parameterName}' was successfully read."); - } - else if (!_options.AllowUnknownQueryStringParameters) - { - throw new InvalidQueryStringParameterException(parameterName, "Unknown query string parameter.", - $"Query string parameter '{parameterName}' is unknown. Set '{nameof(IJsonApiOptions.AllowUnknownQueryStringParameters)}' " + - "to 'true' in options to ignore unknown parameters."); - } - } - } -} diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs deleted file mode 100644 index 5fa60e7f66..0000000000 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs +++ /dev/null @@ -1,99 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers.Annotations; -using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal.Parsing; -using JsonApiDotNetCore.Resources.Annotations; -using Microsoft.Extensions.Primitives; - -namespace JsonApiDotNetCore.QueryStrings.Internal; - -[PublicAPI] -public class SortQueryStringParameterReader : QueryStringParameterReader, ISortQueryStringParameterReader -{ - private readonly QueryStringParameterScopeParser _scopeParser; - private readonly SortParser _sortParser; - private readonly List _constraints = new(); - private string? _lastParameterName; - - public bool AllowEmptyValue => false; - - public SortQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph) - : base(request, resourceGraph) - { - _scopeParser = new QueryStringParameterScopeParser(FieldChainRequirements.EndsInToMany); - _sortParser = new SortParser(ValidateSingleField); - } - - protected void ValidateSingleField(ResourceFieldAttribute field, ResourceType resourceType, string path) - { - if (field is AttrAttribute attribute && !attribute.Capabilities.HasFlag(AttrCapabilities.AllowSort)) - { - throw new InvalidQueryStringParameterException(_lastParameterName!, "Sorting on the requested attribute is not allowed.", - $"Sorting on attribute '{attribute.PublicName}' is not allowed."); - } - } - - /// - public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) - { - ArgumentGuard.NotNull(disableQueryStringAttribute, nameof(disableQueryStringAttribute)); - - return !IsAtomicOperationsRequest && !disableQueryStringAttribute.ContainsParameter(JsonApiQueryStringParameters.Sort); - } - - /// - public virtual bool CanRead(string parameterName) - { - ArgumentGuard.NotNullNorEmpty(parameterName, nameof(parameterName)); - - bool isNested = parameterName.StartsWith("sort[", StringComparison.Ordinal) && parameterName.EndsWith("]", StringComparison.Ordinal); - return parameterName == "sort" || isNested; - } - - /// - public virtual void Read(string parameterName, StringValues parameterValue) - { - _lastParameterName = parameterName; - - try - { - ResourceFieldChainExpression? scope = GetScope(parameterName); - SortExpression sort = GetSort(parameterValue, scope); - - var expressionInScope = new ExpressionInScope(scope, sort); - _constraints.Add(expressionInScope); - } - catch (QueryParseException exception) - { - throw new InvalidQueryStringParameterException(parameterName, "The specified sort is invalid.", exception.Message, exception); - } - } - - private ResourceFieldChainExpression? GetScope(string parameterName) - { - QueryStringParameterScopeExpression parameterScope = _scopeParser.Parse(parameterName, RequestResourceType); - - if (parameterScope.Scope == null) - { - AssertIsCollectionRequest(); - } - - return parameterScope.Scope; - } - - private SortExpression GetSort(string parameterValue, ResourceFieldChainExpression? scope) - { - ResourceType resourceTypeInScope = GetResourceTypeForScope(scope); - return _sortParser.Parse(parameterValue, resourceTypeInScope); - } - - /// - public virtual IReadOnlyCollection GetConstraints() - { - return _constraints; - } -} diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs deleted file mode 100644 index d8f1e858ea..0000000000 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs +++ /dev/null @@ -1,106 +0,0 @@ -using System.Collections.Immutable; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers.Annotations; -using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal.Parsing; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using Microsoft.Extensions.Primitives; - -namespace JsonApiDotNetCore.QueryStrings.Internal; - -[PublicAPI] -public class SparseFieldSetQueryStringParameterReader : QueryStringParameterReader, ISparseFieldSetQueryStringParameterReader -{ - private readonly SparseFieldTypeParser _sparseFieldTypeParser; - private readonly SparseFieldSetParser _sparseFieldSetParser; - - private readonly ImmutableDictionary.Builder _sparseFieldTableBuilder = - ImmutableDictionary.CreateBuilder(); - - private string? _lastParameterName; - - /// - bool IQueryStringParameterReader.AllowEmptyValue => true; - - public SparseFieldSetQueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph) - : base(request, resourceGraph) - { - _sparseFieldTypeParser = new SparseFieldTypeParser(resourceGraph); - _sparseFieldSetParser = new SparseFieldSetParser(ValidateSingleField); - } - - protected void ValidateSingleField(ResourceFieldAttribute field, ResourceType resourceType, string path) - { - if (field is AttrAttribute attribute && !attribute.Capabilities.HasFlag(AttrCapabilities.AllowView)) - { - throw new InvalidQueryStringParameterException(_lastParameterName!, "Retrieving the requested attribute is not allowed.", - $"Retrieving the attribute '{attribute.PublicName}' is not allowed."); - } - } - - /// - public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) - { - ArgumentGuard.NotNull(disableQueryStringAttribute, nameof(disableQueryStringAttribute)); - - return !IsAtomicOperationsRequest && !disableQueryStringAttribute.ContainsParameter(JsonApiQueryStringParameters.Fields); - } - - /// - public virtual bool CanRead(string parameterName) - { - ArgumentGuard.NotNullNorEmpty(parameterName, nameof(parameterName)); - - return parameterName.StartsWith("fields[", StringComparison.Ordinal) && parameterName.EndsWith("]", StringComparison.Ordinal); - } - - /// - public virtual void Read(string parameterName, StringValues parameterValue) - { - _lastParameterName = parameterName; - - try - { - ResourceType targetResourceType = GetSparseFieldType(parameterName); - SparseFieldSetExpression sparseFieldSet = GetSparseFieldSet(parameterValue, targetResourceType); - - _sparseFieldTableBuilder[targetResourceType] = sparseFieldSet; - } - catch (QueryParseException exception) - { - throw new InvalidQueryStringParameterException(parameterName, "The specified fieldset is invalid.", exception.Message, exception); - } - } - - private ResourceType GetSparseFieldType(string parameterName) - { - return _sparseFieldTypeParser.Parse(parameterName); - } - - private SparseFieldSetExpression GetSparseFieldSet(string parameterValue, ResourceType resourceType) - { - SparseFieldSetExpression? sparseFieldSet = _sparseFieldSetParser.Parse(parameterValue, resourceType); - - if (sparseFieldSet == null) - { - // We add ID on an incoming empty fieldset, so that callers can distinguish between no fieldset and an empty one. - AttrAttribute idAttribute = resourceType.GetAttributeByPropertyName(nameof(Identifiable.Id)); - return new SparseFieldSetExpression(ImmutableHashSet.Create(idAttribute)); - } - - return sparseFieldSet; - } - - /// - public virtual IReadOnlyCollection GetConstraints() - { - return _sparseFieldTableBuilder.Any() - ? new ExpressionInScope(null, new SparseFieldTableExpression(_sparseFieldTableBuilder.ToImmutable())).AsArray() - : Array.Empty(); - } -} diff --git a/src/JsonApiDotNetCore/QueryStrings/JsonApiQueryStringParameters.cs b/src/JsonApiDotNetCore/QueryStrings/JsonApiQueryStringParameters.cs index c1f59ceab6..e7c418d8a7 100644 --- a/src/JsonApiDotNetCore/QueryStrings/JsonApiQueryStringParameters.cs +++ b/src/JsonApiDotNetCore/QueryStrings/JsonApiQueryStringParameters.cs @@ -10,9 +10,9 @@ public enum JsonApiQueryStringParameters { None = 0, Filter = 1, - Sort = 2, - Include = 4, - Page = 8, - Fields = 16, + Sort = 1 << 1, + Include = 1 << 2, + Page = 1 << 3, + Fields = 1 << 4, All = Filter | Sort | Include | Page | Fields } diff --git a/src/JsonApiDotNetCore/QueryStrings/LegacyFilterNotationConverter.cs b/src/JsonApiDotNetCore/QueryStrings/LegacyFilterNotationConverter.cs new file mode 100644 index 0000000000..eff147e3d4 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/LegacyFilterNotationConverter.cs @@ -0,0 +1,150 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Queries.Parsing; + +namespace JsonApiDotNetCore.QueryStrings; + +[PublicAPI] +public sealed class LegacyFilterNotationConverter +{ + private const string ParameterNamePrefix = "filter["; + private const string ParameterNameSuffix = "]"; + private const string OutputParameterName = "filter"; + + private static readonly Dictionary PrefixConversionTable = new() + { + [ParameterValuePrefix.Equal] = Keywords.Equals, + [ParameterValuePrefix.LessThan] = Keywords.LessThan, + [ParameterValuePrefix.LessOrEqual] = Keywords.LessOrEqual, + [ParameterValuePrefix.GreaterThan] = Keywords.GreaterThan, + [ParameterValuePrefix.GreaterEqual] = Keywords.GreaterOrEqual, + [ParameterValuePrefix.Like] = Keywords.Contains + }; + + public IEnumerable ExtractConditions(string parameterValue) + { + ArgumentException.ThrowIfNullOrEmpty(parameterValue); + + if (parameterValue.StartsWith(ParameterValuePrefix.Expression, StringComparison.Ordinal) || + parameterValue.StartsWith(ParameterValuePrefix.In, StringComparison.Ordinal) || + parameterValue.StartsWith(ParameterValuePrefix.NotIn, StringComparison.Ordinal)) + { + yield return parameterValue; + } + else + { + foreach (string condition in parameterValue.Split(',')) + { + yield return condition; + } + } + } + + public (string parameterName, string parameterValue) Convert(string parameterName, string parameterValue) + { + ArgumentException.ThrowIfNullOrEmpty(parameterName); + ArgumentException.ThrowIfNullOrEmpty(parameterValue); + + if (parameterValue.StartsWith(ParameterValuePrefix.Expression, StringComparison.Ordinal)) + { + string expression = parameterValue[ParameterValuePrefix.Expression.Length..]; + return (parameterName, expression); + } + + string attributeName = ExtractAttributeName(parameterName); + + foreach ((string prefix, string keyword) in PrefixConversionTable) + { + if (parameterValue.StartsWith(prefix, StringComparison.Ordinal)) + { + string value = parameterValue[prefix.Length..]; + string escapedValue = EscapeQuotes(value); + string expression = $"{keyword}({attributeName},'{escapedValue}')"; + + return (OutputParameterName, expression); + } + } + + if (parameterValue.StartsWith(ParameterValuePrefix.NotEqual, StringComparison.Ordinal)) + { + string value = parameterValue[ParameterValuePrefix.NotEqual.Length..]; + string escapedValue = EscapeQuotes(value); + string expression = $"{Keywords.Not}({Keywords.Equals}({attributeName},'{escapedValue}'))"; + + return (OutputParameterName, expression); + } + + if (parameterValue.StartsWith(ParameterValuePrefix.In, StringComparison.Ordinal)) + { + string[] valueParts = parameterValue[ParameterValuePrefix.In.Length..].Split(","); + string valueList = $"'{string.Join("','", valueParts)}'"; + string expression = $"{Keywords.Any}({attributeName},{valueList})"; + + return (OutputParameterName, expression); + } + + if (parameterValue.StartsWith(ParameterValuePrefix.NotIn, StringComparison.Ordinal)) + { + string[] valueParts = parameterValue[ParameterValuePrefix.NotIn.Length..].Split(","); + string valueList = $"'{string.Join("','", valueParts)}'"; + string expression = $"{Keywords.Not}({Keywords.Any}({attributeName},{valueList}))"; + + return (OutputParameterName, expression); + } + + if (parameterValue == ParameterValuePrefix.IsNull) + { + string expression = $"{Keywords.Equals}({attributeName},null)"; + return (OutputParameterName, expression); + } + + if (parameterValue == ParameterValuePrefix.IsNotNull) + { + string expression = $"{Keywords.Not}({Keywords.Equals}({attributeName},null))"; + return (OutputParameterName, expression); + } + + { + string escapedValue = EscapeQuotes(parameterValue); + string expression = $"{Keywords.Equals}({attributeName},'{escapedValue}')"; + + return (OutputParameterName, expression); + } + } + + private static string ExtractAttributeName(string parameterName) + { + if (parameterName.StartsWith(ParameterNamePrefix, StringComparison.Ordinal) && parameterName.EndsWith(ParameterNameSuffix, StringComparison.Ordinal)) + { + string attributeName = parameterName.Substring(ParameterNamePrefix.Length, + parameterName.Length - ParameterNamePrefix.Length - ParameterNameSuffix.Length); + + if (attributeName.Length > 0) + { + return attributeName; + } + } + + throw new QueryParseException("Expected field name between brackets in filter parameter name.", -1); + } + + private static string EscapeQuotes(string text) + { + return text.Replace("'", "''"); + } + + private sealed class ParameterValuePrefix + { + public const string Equal = "eq:"; + public const string NotEqual = "ne:"; + public const string LessThan = "lt:"; + public const string LessOrEqual = "le:"; + public const string GreaterThan = "gt:"; + public const string GreaterEqual = "ge:"; + public const string Like = "like:"; + public const string In = "in:"; + public const string NotIn = "nin:"; + public const string IsNull = "isnull:"; + public const string IsNotNull = "isnotnull:"; + public const string Expression = "expr:"; + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/PaginationQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/PaginationQueryStringParameterReader.cs new file mode 100644 index 0000000000..353204eee2 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/PaginationQueryStringParameterReader.cs @@ -0,0 +1,227 @@ +using System.Collections.Immutable; +using System.Collections.ObjectModel; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCore.QueryStrings; + +/// +[PublicAPI] +public class PaginationQueryStringParameterReader : QueryStringParameterReader, IPaginationQueryStringParameterReader +{ + private const string PageSizeParameterName = "page[size]"; + private const string PageNumberParameterName = "page[number]"; + + private readonly IJsonApiOptions _options; + private readonly IPaginationParser _paginationParser; + + private PaginationQueryStringValueExpression? _pageSizeConstraint; + private PaginationQueryStringValueExpression? _pageNumberConstraint; + + public bool AllowEmptyValue => false; + + public PaginationQueryStringParameterReader(IPaginationParser paginationParser, IJsonApiRequest request, IResourceGraph resourceGraph, + IJsonApiOptions options) + : base(request, resourceGraph) + { + ArgumentNullException.ThrowIfNull(paginationParser); + ArgumentNullException.ThrowIfNull(options); + + _options = options; + _paginationParser = paginationParser; + } + + /// + public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) + { + ArgumentNullException.ThrowIfNull(disableQueryStringAttribute); + + return !IsAtomicOperationsRequest && !disableQueryStringAttribute.ContainsParameter(JsonApiQueryStringParameters.Page); + } + + /// + public virtual bool CanRead(string parameterName) + { + ArgumentException.ThrowIfNullOrEmpty(parameterName); + + return parameterName is PageSizeParameterName or PageNumberParameterName; + } + + /// + public virtual void Read(string parameterName, StringValues parameterValue) + { + bool isParameterNameValid = true; + + try + { + PaginationQueryStringValueExpression constraint = GetPageConstraint(parameterValue.ToString()); + + if (constraint.Elements.Any(element => element.Scope == null)) + { + isParameterNameValid = false; + AssertIsCollectionRequest(); + isParameterNameValid = true; + } + + if (parameterName == PageSizeParameterName) + { + ValidatePageSize(constraint); + _pageSizeConstraint = constraint; + } + else + { + ValidatePageNumber(constraint); + _pageNumberConstraint = constraint; + } + } + catch (QueryParseException exception) + { + string specificMessage = exception.GetMessageWithPosition(isParameterNameValid ? parameterValue.ToString() : parameterName); + throw new InvalidQueryStringParameterException(parameterName, "The specified pagination is invalid.", specificMessage, exception); + } + } + + private PaginationQueryStringValueExpression GetPageConstraint(string parameterValue) + { + return _paginationParser.Parse(parameterValue, RequestResourceType); + } + + protected virtual void ValidatePageSize(PaginationQueryStringValueExpression constraint) + { + ArgumentNullException.ThrowIfNull(constraint); + + foreach (PaginationElementQueryStringValueExpression element in constraint.Elements) + { + if (_options.MaximumPageSize != null) + { + if (element.Value > _options.MaximumPageSize.Value) + { + throw new QueryParseException($"Page size cannot be higher than {_options.MaximumPageSize}.", element.Position); + } + + if (element.Value == 0) + { + throw new QueryParseException("Page size cannot be unconstrained.", element.Position); + } + } + + if (element.Value < 0) + { + throw new QueryParseException("Page size cannot be negative.", element.Position); + } + } + } + + protected virtual void ValidatePageNumber(PaginationQueryStringValueExpression constraint) + { + ArgumentNullException.ThrowIfNull(constraint); + + foreach (PaginationElementQueryStringValueExpression element in constraint.Elements) + { + if (_options.MaximumPageNumber != null) + { + if (element.Value > _options.MaximumPageNumber.OneBasedValue) + { + throw new QueryParseException($"Page number cannot be higher than {_options.MaximumPageNumber}.", element.Position); + } + } + + if (element.Value < 1) + { + throw new QueryParseException("Page number cannot be negative or zero.", element.Position); + } + } + } + + /// + public virtual IReadOnlyCollection GetConstraints() + { + var paginationState = new PaginationState(); + + foreach (PaginationElementQueryStringValueExpression element in _pageSizeConstraint?.Elements ?? + ImmutableArray.Empty) + { + MutablePaginationEntry entry = paginationState.ResolveEntryInScope(element.Scope); + entry.PageSize = element.Value == 0 ? null : new PageSize(element.Value); + entry.HasSetPageSize = true; + } + + foreach (PaginationElementQueryStringValueExpression element in _pageNumberConstraint?.Elements ?? + ImmutableArray.Empty) + { + MutablePaginationEntry entry = paginationState.ResolveEntryInScope(element.Scope); + entry.PageNumber = new PageNumber(element.Value); + } + + paginationState.ApplyOptions(_options); + + return paginationState.GetExpressionsInScope(); + } + + private sealed class PaginationState + { + private readonly MutablePaginationEntry _globalScope = new(); + private readonly Dictionary _nestedScopes = []; + + public MutablePaginationEntry ResolveEntryInScope(ResourceFieldChainExpression? scope) + { + if (scope == null) + { + return _globalScope; + } + + _nestedScopes.TryAdd(scope, new MutablePaginationEntry()); + return _nestedScopes[scope]; + } + + public void ApplyOptions(IJsonApiOptions options) + { + ApplyOptionsInEntry(_globalScope, options); + + foreach ((_, MutablePaginationEntry entry) in _nestedScopes) + { + ApplyOptionsInEntry(entry, options); + } + } + + private void ApplyOptionsInEntry(MutablePaginationEntry entry, IJsonApiOptions options) + { + if (!entry.HasSetPageSize) + { + entry.PageSize = options.DefaultPageSize; + } + + entry.PageNumber ??= PageNumber.ValueOne; + } + + public ReadOnlyCollection GetExpressionsInScope() + { + return EnumerateExpressionsInScope().ToArray().AsReadOnly(); + } + + private IEnumerable EnumerateExpressionsInScope() + { + yield return new ExpressionInScope(null, new PaginationExpression(_globalScope.PageNumber!, _globalScope.PageSize)); + + foreach ((ResourceFieldChainExpression scope, MutablePaginationEntry entry) in _nestedScopes) + { + yield return new ExpressionInScope(scope, new PaginationExpression(entry.PageNumber!, entry.PageSize)); + } + } + } + + private sealed class MutablePaginationEntry + { + public PageSize? PageSize { get; set; } + public bool HasSetPageSize { get; set; } + + public PageNumber? PageNumber { get; set; } + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/QueryStringParameterReader.cs similarity index 86% rename from src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringParameterReader.cs rename to src/JsonApiDotNetCore/QueryStrings/QueryStringParameterReader.cs index 103429aa81..1b59eed083 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/QueryStringParameterReader.cs @@ -1,10 +1,10 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.QueryStrings.Internal; +namespace JsonApiDotNetCore.QueryStrings; public abstract class QueryStringParameterReader { @@ -16,8 +16,8 @@ public abstract class QueryStringParameterReader protected QueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph) { - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(resourceGraph); _resourceGraph = resourceGraph; _isCollectionRequest = request.IsCollection; @@ -47,7 +47,7 @@ protected void AssertIsCollectionRequest() { if (!_isCollectionRequest) { - throw new QueryParseException("This query string parameter can only be used on a collection of resources (not on a single resource)."); + throw new QueryParseException("This query string parameter can only be used on a collection of resources (not on a single resource).", 0); } } } diff --git a/src/JsonApiDotNetCore/QueryStrings/QueryStringReader.cs b/src/JsonApiDotNetCore/QueryStrings/QueryStringReader.cs new file mode 100644 index 0000000000..89337708e7 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/QueryStringReader.cs @@ -0,0 +1,82 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Diagnostics; +using JsonApiDotNetCore.Errors; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCore.QueryStrings; + +/// +public sealed partial class QueryStringReader : IQueryStringReader +{ + private readonly IJsonApiOptions _options; + private readonly IRequestQueryStringAccessor _queryStringAccessor; + private readonly IQueryStringParameterReader[] _parameterReaders; + private readonly ILogger _logger; + + public QueryStringReader(IJsonApiOptions options, IRequestQueryStringAccessor queryStringAccessor, + IEnumerable parameterReaders, ILoggerFactory loggerFactory) + { + ArgumentNullException.ThrowIfNull(loggerFactory); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(queryStringAccessor); + ArgumentNullException.ThrowIfNull(parameterReaders); + + _options = options; + _queryStringAccessor = queryStringAccessor; + _parameterReaders = parameterReaders as IQueryStringParameterReader[] ?? parameterReaders.ToArray(); + _logger = loggerFactory.CreateLogger(); + } + + /// + public void ReadAll(DisableQueryStringAttribute? disableQueryStringAttribute) + { + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Parse query string"); + + DisableQueryStringAttribute disableQueryStringAttributeNotNull = disableQueryStringAttribute ?? DisableQueryStringAttribute.Empty; + + foreach ((string parameterName, StringValues parameterValue) in _queryStringAccessor.Query) + { + if (parameterName.Length == 0) + { + continue; + } + + IQueryStringParameterReader? reader = _parameterReaders.FirstOrDefault(nextReader => nextReader.CanRead(parameterName)); + + if (reader != null) + { + LogParameterAccepted(parameterName, parameterValue, reader.GetType().Name); + + if (!reader.AllowEmptyValue && string.IsNullOrEmpty(parameterValue)) + { + throw new InvalidQueryStringParameterException(parameterName, "Missing query string parameter value.", + $"Missing value for '{parameterName}' query string parameter."); + } + + if (!reader.IsEnabled(disableQueryStringAttributeNotNull)) + { + throw new InvalidQueryStringParameterException(parameterName, + "Usage of one or more query string parameters is not allowed at the requested endpoint.", + $"The parameter '{parameterName}' cannot be used at this endpoint."); + } + + reader.Read(parameterName, parameterValue); + LogParameterRead(parameterName); + } + else if (!_options.AllowUnknownQueryStringParameters) + { + throw new InvalidQueryStringParameterException(parameterName, "Unknown query string parameter.", + $"Query string parameter '{parameterName}' is unknown. Set '{nameof(IJsonApiOptions.AllowUnknownQueryStringParameters)}' " + + "to 'true' in options to ignore unknown parameters."); + } + } + } + + [LoggerMessage(Level = LogLevel.Debug, Message = "Query string parameter '{ParameterName}' with value '{ParameterValue}' was accepted by {ReaderType}.")] + private partial void LogParameterAccepted(string parameterName, StringValues parameterValue, string readerType); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Query string parameter '{ParameterName}' was successfully read.")] + private partial void LogParameterRead(string parameterName); +} diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/RequestQueryStringAccessor.cs b/src/JsonApiDotNetCore/QueryStrings/RequestQueryStringAccessor.cs similarity index 80% rename from src/JsonApiDotNetCore/QueryStrings/Internal/RequestQueryStringAccessor.cs rename to src/JsonApiDotNetCore/QueryStrings/RequestQueryStringAccessor.cs index 2492f01001..92b9a0a60a 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/RequestQueryStringAccessor.cs +++ b/src/JsonApiDotNetCore/QueryStrings/RequestQueryStringAccessor.cs @@ -1,8 +1,8 @@ using Microsoft.AspNetCore.Http; -namespace JsonApiDotNetCore.QueryStrings.Internal; +namespace JsonApiDotNetCore.QueryStrings; -/// +/// internal sealed class RequestQueryStringAccessor : IRequestQueryStringAccessor { private readonly IHttpContextAccessor _httpContextAccessor; @@ -22,7 +22,7 @@ public IQueryCollection Query public RequestQueryStringAccessor(IHttpContextAccessor httpContextAccessor) { - ArgumentGuard.NotNull(httpContextAccessor, nameof(httpContextAccessor)); + ArgumentNullException.ThrowIfNull(httpContextAccessor); _httpContextAccessor = httpContextAccessor; } diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/ResourceDefinitionQueryableParameterReader.cs similarity index 85% rename from src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs rename to src/JsonApiDotNetCore/QueryStrings/ResourceDefinitionQueryableParameterReader.cs index 51c5de0583..fb52e5775d 100644 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/ResourceDefinitionQueryableParameterReader.cs @@ -7,22 +7,22 @@ using JsonApiDotNetCore.Resources; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCore.QueryStrings.Internal; +namespace JsonApiDotNetCore.QueryStrings; -/// +/// [PublicAPI] public class ResourceDefinitionQueryableParameterReader : IResourceDefinitionQueryableParameterReader { private readonly IJsonApiRequest _request; private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; - private readonly List _constraints = new(); + private readonly List _constraints = []; public bool AllowEmptyValue => false; public ResourceDefinitionQueryableParameterReader(IJsonApiRequest request, IResourceDefinitionAccessor resourceDefinitionAccessor) { - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(resourceDefinitionAccessor); _request = request; _resourceDefinitionAccessor = resourceDefinitionAccessor; @@ -37,6 +37,8 @@ public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttr /// public virtual bool CanRead(string parameterName) { + ArgumentException.ThrowIfNullOrEmpty(parameterName); + if (_request.Kind == EndpointKind.AtomicOperations) { return false; @@ -71,6 +73,6 @@ public virtual void Read(string parameterName, StringValues parameterValue) /// public virtual IReadOnlyCollection GetConstraints() { - return _constraints; + return _constraints.AsReadOnly(); } } diff --git a/src/JsonApiDotNetCore/QueryStrings/SortQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/SortQueryStringParameterReader.cs new file mode 100644 index 0000000000..203ee70235 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/SortQueryStringParameterReader.cs @@ -0,0 +1,97 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings.FieldChains; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCore.QueryStrings; + +/// +[PublicAPI] +public class SortQueryStringParameterReader : QueryStringParameterReader, ISortQueryStringParameterReader +{ + private readonly IQueryStringParameterScopeParser _scopeParser; + private readonly ISortParser _sortParser; + private readonly List _constraints = []; + + public bool AllowEmptyValue => false; + + public SortQueryStringParameterReader(IQueryStringParameterScopeParser scopeParser, ISortParser sortParser, IJsonApiRequest request, + IResourceGraph resourceGraph) + : base(request, resourceGraph) + { + ArgumentNullException.ThrowIfNull(scopeParser); + ArgumentNullException.ThrowIfNull(sortParser); + + _scopeParser = scopeParser; + _sortParser = sortParser; + } + + /// + public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) + { + ArgumentNullException.ThrowIfNull(disableQueryStringAttribute); + + return !IsAtomicOperationsRequest && !disableQueryStringAttribute.ContainsParameter(JsonApiQueryStringParameters.Sort); + } + + /// + public virtual bool CanRead(string parameterName) + { + ArgumentException.ThrowIfNullOrEmpty(parameterName); + + bool isNested = parameterName.StartsWith("sort[", StringComparison.Ordinal) && parameterName.EndsWith(']'); + return parameterName == "sort" || isNested; + } + + /// + public virtual void Read(string parameterName, StringValues parameterValue) + { + bool parameterNameIsValid = false; + + try + { + ResourceFieldChainExpression? scope = GetScope(parameterName); + parameterNameIsValid = true; + + SortExpression sort = GetSort(parameterValue.ToString(), scope); + var expressionInScope = new ExpressionInScope(scope, sort); + _constraints.Add(expressionInScope); + } + catch (QueryParseException exception) + { + string specificMessage = exception.GetMessageWithPosition(parameterNameIsValid ? parameterValue.ToString() : parameterName); + throw new InvalidQueryStringParameterException(parameterName, "The specified sort is invalid.", specificMessage, exception); + } + } + + private ResourceFieldChainExpression? GetScope(string parameterName) + { + QueryStringParameterScopeExpression parameterScope = _scopeParser.Parse(parameterName, RequestResourceType, + BuiltInPatterns.RelationshipChainEndingInToMany, FieldChainPatternMatchOptions.None); + + if (parameterScope.Scope == null) + { + AssertIsCollectionRequest(); + } + + return parameterScope.Scope; + } + + private SortExpression GetSort(string parameterValue, ResourceFieldChainExpression? scope) + { + ResourceType resourceTypeInScope = GetResourceTypeForScope(scope); + return _sortParser.Parse(parameterValue, resourceTypeInScope); + } + + /// + public virtual IReadOnlyCollection GetConstraints() + { + return _constraints.AsReadOnly(); + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/SparseFieldSetQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/SparseFieldSetQueryStringParameterReader.cs new file mode 100644 index 0000000000..308fe44588 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/SparseFieldSetQueryStringParameterReader.cs @@ -0,0 +1,102 @@ +using System.Collections.Immutable; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCore.QueryStrings; + +/// +[PublicAPI] +public class SparseFieldSetQueryStringParameterReader : QueryStringParameterReader, ISparseFieldSetQueryStringParameterReader +{ + private readonly ISparseFieldTypeParser _scopeParser; + private readonly ISparseFieldSetParser _sparseFieldSetParser; + + private readonly ImmutableDictionary.Builder _sparseFieldTableBuilder = + ImmutableDictionary.CreateBuilder(); + + /// + public bool AllowEmptyValue => true; + + public SparseFieldSetQueryStringParameterReader(ISparseFieldTypeParser scopeParser, ISparseFieldSetParser sparseFieldSetParser, IJsonApiRequest request, + IResourceGraph resourceGraph) + : base(request, resourceGraph) + { + ArgumentNullException.ThrowIfNull(scopeParser); + ArgumentNullException.ThrowIfNull(sparseFieldSetParser); + + _scopeParser = scopeParser; + _sparseFieldSetParser = sparseFieldSetParser; + } + + /// + public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) + { + ArgumentNullException.ThrowIfNull(disableQueryStringAttribute); + + return !IsAtomicOperationsRequest && !disableQueryStringAttribute.ContainsParameter(JsonApiQueryStringParameters.Fields); + } + + /// + public virtual bool CanRead(string parameterName) + { + ArgumentException.ThrowIfNullOrEmpty(parameterName); + + return parameterName.StartsWith("fields[", StringComparison.Ordinal) && parameterName.EndsWith(']'); + } + + /// + public virtual void Read(string parameterName, StringValues parameterValue) + { + bool parameterNameIsValid = false; + + try + { + ResourceType resourceType = GetScope(parameterName); + parameterNameIsValid = true; + + SparseFieldSetExpression sparseFieldSet = GetSparseFieldSet(parameterValue.ToString(), resourceType); + _sparseFieldTableBuilder[resourceType] = sparseFieldSet; + } + catch (QueryParseException exception) + { + string specificMessage = exception.GetMessageWithPosition(parameterNameIsValid ? parameterValue.ToString() : parameterName); + throw new InvalidQueryStringParameterException(parameterName, "The specified fieldset is invalid.", specificMessage, exception); + } + } + + private ResourceType GetScope(string parameterName) + { + return _scopeParser.Parse(parameterName); + } + + private SparseFieldSetExpression GetSparseFieldSet(string parameterValue, ResourceType resourceType) + { + SparseFieldSetExpression? sparseFieldSet = _sparseFieldSetParser.Parse(parameterValue, resourceType); + + if (sparseFieldSet == null) + { + // We add ID to an incoming empty fieldset, so that callers can distinguish between no fieldset and an empty one. + AttrAttribute idAttribute = resourceType.GetAttributeByPropertyName(nameof(Identifiable.Id)); + return new SparseFieldSetExpression(ImmutableHashSet.Create(idAttribute)); + } + + return sparseFieldSet; + } + + /// + public virtual IReadOnlyCollection GetConstraints() + { + return _sparseFieldTableBuilder.Count > 0 + ? [new ExpressionInScope(null, new SparseFieldTableExpression(_sparseFieldTableBuilder.ToImmutable()))] + : Array.Empty(); + } +} diff --git a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs index e823b50077..9fb9b482cd 100644 --- a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs +++ b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs @@ -6,10 +6,5 @@ namespace JsonApiDotNetCore.Repositories; /// The error that is thrown when the underlying data store is unable to persist changes. /// [PublicAPI] -public sealed class DataStoreUpdateException : Exception -{ - public DataStoreUpdateException(Exception? innerException) - : base("Failed to persist changes in the underlying data store.", innerException) - { - } -} +public sealed class DataStoreUpdateException(Exception? innerException) + : Exception("Failed to persist changes in the underlying data store.", innerException); diff --git a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs index cadbd658a8..0e7089acec 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs @@ -13,8 +13,8 @@ public static class DbContextExtensions /// public static IIdentifiable GetTrackedOrAttach(this DbContext dbContext, IIdentifiable resource) { - ArgumentGuard.NotNull(dbContext, nameof(dbContext)); - ArgumentGuard.NotNull(resource, nameof(resource)); + ArgumentNullException.ThrowIfNull(dbContext); + ArgumentNullException.ThrowIfNull(resource); var trackedIdentifiable = (IIdentifiable?)dbContext.GetTrackedIdentifiable(resource); @@ -32,8 +32,8 @@ public static IIdentifiable GetTrackedOrAttach(this DbContext dbContext, IIdenti /// public static object? GetTrackedIdentifiable(this DbContext dbContext, IIdentifiable identifiable) { - ArgumentGuard.NotNull(dbContext, nameof(dbContext)); - ArgumentGuard.NotNull(identifiable, nameof(identifiable)); + ArgumentNullException.ThrowIfNull(dbContext); + ArgumentNullException.ThrowIfNull(identifiable); Type resourceClrType = identifiable.GetClrType(); string? stringId = identifiable.StringId; @@ -53,7 +53,7 @@ private static bool IsResource(EntityEntry entry, Type resourceClrType, string? /// public static void ResetChangeTracker(this DbContext dbContext) { - ArgumentGuard.NotNull(dbContext, nameof(dbContext)); + ArgumentNullException.ThrowIfNull(dbContext); dbContext.ChangeTracker.Clear(); } diff --git a/src/JsonApiDotNetCore/Repositories/DbContextResolver.cs b/src/JsonApiDotNetCore/Repositories/DbContextResolver.cs index c8013a5f0a..c48169df72 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextResolver.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextResolver.cs @@ -3,7 +3,10 @@ namespace JsonApiDotNetCore.Repositories; -/// +/// +/// +/// The type of the to resolve. +/// [PublicAPI] public sealed class DbContextResolver : IDbContextResolver where TDbContext : DbContext @@ -12,7 +15,7 @@ public sealed class DbContextResolver : IDbContextResolver public DbContextResolver(TDbContext dbContext) { - ArgumentGuard.NotNull(dbContext, nameof(dbContext)); + ArgumentNullException.ThrowIfNull(dbContext); _dbContext = dbContext; } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 7384cec693..f4c9af37c0 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -8,7 +8,7 @@ using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal.QueryableBuilding; +using JsonApiDotNetCore.Queries.QueryableBuilding; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore; @@ -21,16 +21,21 @@ namespace JsonApiDotNetCore.Repositories; /// /// Implements the foundational Repository layer in the JsonApiDotNetCore architecture that uses Entity Framework Core. /// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// [PublicAPI] public class EntityFrameworkCoreRepository : IResourceRepository, IRepositorySupportsTransaction where TResource : class, IIdentifiable { - private readonly CollectionConverter _collectionConverter = new(); private readonly ITargetedFields _targetedFields; private readonly DbContext _dbContext; private readonly IResourceGraph _resourceGraph; private readonly IResourceFactory _resourceFactory; - private readonly IEnumerable _constraintProviders; + private readonly IQueryConstraintProvider[] _constraintProviders; private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; private readonly TraceLogWriter> _traceWriter; @@ -41,19 +46,19 @@ public EntityFrameworkCoreRepository(ITargetedFields targetedFields, IDbContextR IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, IResourceDefinitionAccessor resourceDefinitionAccessor) { - ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); - ArgumentGuard.NotNull(dbContextResolver, nameof(dbContextResolver)); - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - ArgumentGuard.NotNull(resourceFactory, nameof(resourceFactory)); - ArgumentGuard.NotNull(constraintProviders, nameof(constraintProviders)); - ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); - ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); + ArgumentNullException.ThrowIfNull(targetedFields); + ArgumentNullException.ThrowIfNull(dbContextResolver); + ArgumentNullException.ThrowIfNull(resourceGraph); + ArgumentNullException.ThrowIfNull(resourceFactory); + ArgumentNullException.ThrowIfNull(constraintProviders); + ArgumentNullException.ThrowIfNull(loggerFactory); + ArgumentNullException.ThrowIfNull(resourceDefinitionAccessor); _targetedFields = targetedFields; _dbContext = dbContextResolver.GetContext(); _resourceGraph = resourceGraph; _resourceFactory = resourceFactory; - _constraintProviders = constraintProviders; + _constraintProviders = constraintProviders as IQueryConstraintProvider[] ?? constraintProviders.ToArray(); _resourceDefinitionAccessor = resourceDefinitionAccessor; _traceWriter = new TraceLogWriter>(loggerFactory); } @@ -66,7 +71,7 @@ public virtual async Task> GetAsync(QueryLayer qu queryLayer }); - ArgumentGuard.NotNull(queryLayer, nameof(queryLayer)); + ArgumentNullException.ThrowIfNull(queryLayer); using (CodeTimingSessionManager.Current.Measure("Repository - Get resource(s)")) { @@ -74,7 +79,8 @@ public virtual async Task> GetAsync(QueryLayer qu using (CodeTimingSessionManager.Current.Measure("Execute SQL (data)", MeasurementSettings.ExcludeDatabaseInPercentages)) { - return await query.ToListAsync(cancellationToken); + List resources = await query.ToListAsync(cancellationToken); + return resources.AsReadOnly(); } } } @@ -105,23 +111,25 @@ public virtual async Task CountAsync(FilterExpression? filter, Cancellation } } -#pragma warning disable AV1130 // Return type in method signature should be an interface to an unchangeable collection protected virtual IQueryable ApplyQueryLayer(QueryLayer queryLayer) -#pragma warning restore AV1130 // Return type in method signature should be an interface to an unchangeable collection { + ArgumentNullException.ThrowIfNull(queryLayer); + _traceWriter.LogMethodStart(new { queryLayer }); - ArgumentGuard.NotNull(queryLayer, nameof(queryLayer)); + ArgumentNullException.ThrowIfNull(queryLayer); + + _traceWriter.LogDebug(queryLayer); using (CodeTimingSessionManager.Current.Measure("Convert QueryLayer to System.Expression")) { 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()) @@ -130,7 +138,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) @@ -138,11 +146,14 @@ protected virtual IQueryable ApplyQueryLayer(QueryLayer queryLayer) source = queryableHandler.Apply(source); } - var nameFactory = new LambdaParameterNameFactory(); +#pragma warning disable CS0618 + IQueryableBuilder builder = _resourceDefinitionAccessor.QueryableBuilder; +#pragma warning restore CS0618 - var builder = new QueryableBuilder(source.Expression, source.ElementType, typeof(Queryable), nameFactory, _resourceFactory, _dbContext.Model); + var context = QueryableBuilderContext.CreateRoot(source, typeof(Queryable), _dbContext.Model, null); + Expression expression = builder.ApplyQuery(queryLayer, context); - Expression expression = builder.ApplyQuery(queryLayer); + _traceWriter.LogDebug(expression); using (CodeTimingSessionManager.Current.Measure("Convert System.Expression to IQueryable")) { @@ -151,15 +162,30 @@ protected virtual IQueryable ApplyQueryLayer(QueryLayer queryLayer) } } -#pragma warning disable AV1130 // Return type in method signature should be an interface to an unchangeable collection protected virtual IQueryable GetAll() -#pragma warning restore AV1130 // Return type in method signature should be an interface to an unchangeable collection { - return _dbContext.Set(); + IQueryable source = _dbContext.Set(); + + return GetTrackingBehavior() switch + { + QueryTrackingBehavior.NoTrackingWithIdentityResolution => source.AsNoTrackingWithIdentityResolution(), + QueryTrackingBehavior.NoTracking => source.AsNoTracking(), + QueryTrackingBehavior.TrackAll => source.AsTracking(), + _ => source + }; + } + + protected virtual QueryTrackingBehavior? GetTrackingBehavior() + { + // EF Core rejects the way we project sparse fieldsets when owned entities are involved, unless the query is explicitly + // marked as non-tracked (see https://github.com/dotnet/EntityFramework.Docs/issues/2205#issuecomment-1542914439). +#pragma warning disable CS0618 + return _resourceDefinitionAccessor.IsReadOnlyRequest ? QueryTrackingBehavior.NoTrackingWithIdentityResolution : null; +#pragma warning restore CS0618 } /// - public virtual Task GetForCreateAsync(Type resourceClrType, TId id, CancellationToken cancellationToken) + public virtual Task GetForCreateAsync(Type resourceClrType, [DisallowNull] TId id, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -167,6 +193,8 @@ public virtual Task GetForCreateAsync(Type resourceClrType, TId id, C id }); + ArgumentNullException.ThrowIfNull(resourceClrType); + var resource = (TResource)_resourceFactory.CreateInstance(resourceClrType); resource.Id = id; @@ -182,8 +210,8 @@ public virtual async Task CreateAsync(TResource resourceFromRequest, TResource r resourceForDatabase }); - ArgumentGuard.NotNull(resourceFromRequest, nameof(resourceFromRequest)); - ArgumentGuard.NotNull(resourceForDatabase, nameof(resourceForDatabase)); + ArgumentNullException.ThrowIfNull(resourceFromRequest); + ArgumentNullException.ThrowIfNull(resourceForDatabase); using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Create resource"); @@ -225,7 +253,7 @@ public virtual async Task CreateAsync(TResource resourceFromRequest, TResource r if (relationship is HasManyAttribute hasManyRelationship) { - HashSet rightResourceIds = _collectionConverter.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); + HashSet rightResourceIds = CollectionConverter.Instance.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); await _resourceDefinitionAccessor.OnSetToManyRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, writeOperation, cancellationToken); @@ -244,7 +272,7 @@ await _resourceDefinitionAccessor.OnSetToManyRelationshipAsync(leftResource, has queryLayer }); - ArgumentGuard.NotNull(queryLayer, nameof(queryLayer)); + ArgumentNullException.ThrowIfNull(queryLayer); using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Get resource for update"); @@ -261,8 +289,8 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r resourceFromDatabase }); - ArgumentGuard.NotNull(resourceFromRequest, nameof(resourceFromRequest)); - ArgumentGuard.NotNull(resourceFromDatabase, nameof(resourceFromDatabase)); + ArgumentNullException.ThrowIfNull(resourceFromRequest); + ArgumentNullException.ThrowIfNull(resourceFromDatabase); using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Update resource"); @@ -273,7 +301,7 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r object? rightValueEvaluated = await VisitSetRelationshipAsync(resourceFromDatabase, relationship, rightValue, WriteOperationKind.UpdateResource, cancellationToken); - AssertIsNotClearingRequiredToOneRelationship(relationship, resourceFromDatabase, rightValueEvaluated); + AssertIsNotClearingRequiredToOneRelationship(relationship, rightValueEvaluated); await UpdateRelationshipAsync(relationship, resourceFromDatabase, rightValueEvaluated, cancellationToken); } @@ -292,8 +320,10 @@ public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource r _dbContext.ResetChangeTracker(); } - protected void AssertIsNotClearingRequiredToOneRelationship(RelationshipAttribute relationship, TResource leftResource, object? rightValue) + protected void AssertIsNotClearingRequiredToOneRelationship(RelationshipAttribute relationship, object? rightValue) { + ArgumentNullException.ThrowIfNull(relationship); + if (relationship is HasOneAttribute) { INavigation? navigation = GetNavigation(relationship); @@ -304,13 +334,13 @@ protected void AssertIsNotClearingRequiredToOneRelationship(RelationshipAttribut if (isRelationshipRequired && isClearingRelationship) { string resourceName = _resourceGraph.GetResourceType().PublicName; - throw new CannotClearRequiredRelationshipException(relationship.PublicName, leftResource.StringId!, resourceName); + throw new CannotClearRequiredRelationshipException(relationship.PublicName, resourceName); } } } /// - public virtual async Task DeleteAsync(TResource? resourceFromDatabase, TId id, CancellationToken cancellationToken) + public virtual async Task DeleteAsync(TResource? resourceFromDatabase, [DisallowNull] TId id, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -351,21 +381,12 @@ private NavigationEntry GetNavigationEntry(TResource resource, RelationshipAttri { EntityEntry entityEntry = _dbContext.Entry(resource); - switch (relationship) + return relationship switch { - case HasOneAttribute hasOneRelationship: - { - return entityEntry.Reference(hasOneRelationship.Property.Name); - } - case HasManyAttribute hasManyRelationship: - { - return entityEntry.Collection(hasManyRelationship.Property.Name); - } - default: - { - throw new InvalidOperationException($"Unknown relationship type '{relationship.GetType().Name}'."); - } - } + HasOneAttribute hasOneRelationship => entityEntry.Reference(hasOneRelationship.Property.Name), + HasManyAttribute hasManyRelationship => entityEntry.Collection(hasManyRelationship.Property.Name), + _ => throw new InvalidOperationException($"Unknown relationship type '{relationship.GetType().Name}'.") + }; } private bool RequiresLoadOfRelationshipForDeletion(RelationshipAttribute relationship) @@ -398,7 +419,7 @@ public virtual async Task SetRelationshipAsync(TResource leftResource, object? r rightValue }); - ArgumentGuard.NotNull(leftResource, nameof(leftResource)); + ArgumentNullException.ThrowIfNull(leftResource); using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Set relationship"); @@ -407,7 +428,7 @@ public virtual async Task SetRelationshipAsync(TResource leftResource, object? r object? rightValueEvaluated = await VisitSetRelationshipAsync(leftResource, relationship, rightValue, WriteOperationKind.SetRelationship, cancellationToken); - AssertIsNotClearingRequiredToOneRelationship(relationship, leftResource, rightValueEvaluated); + AssertIsNotClearingRequiredToOneRelationship(relationship, rightValueEvaluated); await UpdateRelationshipAsync(relationship, leftResource, rightValueEvaluated, cancellationToken); @@ -419,7 +440,7 @@ public virtual async Task SetRelationshipAsync(TResource leftResource, object? r } /// - public virtual async Task AddToToManyRelationshipAsync(TResource? leftResource, TId leftId, ISet rightResourceIds, + public virtual async Task AddToToManyRelationshipAsync(TResource? leftResource, [DisallowNull] TId leftId, ISet rightResourceIds, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new @@ -429,7 +450,7 @@ public virtual async Task AddToToManyRelationshipAsync(TResource? leftResource, rightResourceIds }); - ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); + ArgumentNullException.ThrowIfNull(rightResourceIds); using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Add to to-many relationship"); @@ -442,10 +463,10 @@ public virtual async Task AddToToManyRelationshipAsync(TResource? leftResource, await _resourceDefinitionAccessor.OnAddToRelationshipAsync(leftPlaceholderResource, relationship, rightResourceIds, cancellationToken); - if (rightResourceIds.Any()) + if (rightResourceIds.Count > 0) { var leftResourceTracked = (TResource)_dbContext.GetTrackedOrAttach(leftPlaceholderResource); - IEnumerable rightValueToStore = GetRightValueToStoreForAddToToMany(leftResourceTracked, relationship, rightResourceIds); + ISet rightValueToStore = GetRightValueToStoreForAddToToMany(leftResourceTracked, relationship, rightResourceIds); await UpdateRelationshipAsync(relationship, leftResourceTracked, rightValueToStore, cancellationToken); @@ -458,24 +479,25 @@ public virtual async Task AddToToManyRelationshipAsync(TResource? leftResource, } } - private IEnumerable GetRightValueToStoreForAddToToMany(TResource leftResource, HasManyAttribute relationship, ISet rightResourceIdsToAdd) + private ISet GetRightValueToStoreForAddToToMany(TResource leftResource, HasManyAttribute relationship, + ISet rightResourceIdsToAdd) { object? rightValueStored = relationship.GetValue(leftResource); // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true + // @formatter:wrap_after_property_in_chained_method_calls true - HashSet rightResourceIdsStored = _collectionConverter + HashSet rightResourceIdsStored = CollectionConverter.Instance .ExtractResources(rightValueStored) - .Select(rightResource => _dbContext.GetTrackedOrAttach(rightResource)) + .Select(_dbContext.GetTrackedOrAttach) .ToHashSet(IdentifiableComparer.Instance); - // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_after_property_in_chained_method_calls restore // @formatter:wrap_chained_method_calls restore - if (rightResourceIdsStored.Any()) + if (rightResourceIdsStored.Count > 0) { - rightResourceIdsStored.AddRange(rightResourceIdsToAdd); + rightResourceIdsStored.UnionWith(rightResourceIdsToAdd); return rightResourceIdsStored; } @@ -492,8 +514,8 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TResource leftResour rightResourceIds }); - ArgumentGuard.NotNull(leftResource, nameof(leftResource)); - ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); + ArgumentNullException.ThrowIfNull(leftResource); + ArgumentNullException.ThrowIfNull(rightResourceIds); using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Remove from to-many relationship"); @@ -502,7 +524,7 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TResource leftResour await _resourceDefinitionAccessor.OnRemoveFromRelationshipAsync(leftResource, relationship, rightResourceIdsToRemove, cancellationToken); - if (rightResourceIdsToRemove.Any()) + if (rightResourceIdsToRemove.Count > 0) { var leftResourceTracked = (TResource)_dbContext.GetTrackedOrAttach(leftResource); @@ -512,18 +534,18 @@ 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_after_property_in_chained_method_calls true - IIdentifiable[] rightResourceIdsStored = _collectionConverter + IIdentifiable[] rightResourceIdsStored = CollectionConverter.Instance .ExtractResources(rightValueStored) .Concat(extraResourceIdsToRemove) - .Select(rightResource => _dbContext.GetTrackedOrAttach(rightResource)) + .Select(_dbContext.GetTrackedOrAttach) .ToArray(); - // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_after_property_in_chained_method_calls restore // @formatter:wrap_chained_method_calls restore - rightValueStored = _collectionConverter.CopyToTypedCollection(rightResourceIdsStored, relationship.Property.PropertyType); + rightValueStored = CollectionConverter.Instance.CopyToTypedCollection(rightResourceIdsStored, relationship.Property.PropertyType); relationship.SetValue(leftResourceTracked, rightValueStored); MarkRelationshipAsLoaded(leftResourceTracked, relationship); @@ -533,8 +555,6 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TResource leftResour if (!rightResourceIdsToStore.SetEquals(rightResourceIdsStored)) { - AssertIsNotClearingRequiredToOneRelationship(relationship, leftResourceTracked, rightResourceIdsToStore); - await UpdateRelationshipAsync(relationship, leftResourceTracked, rightResourceIdsToStore, cancellationToken); await _resourceDefinitionAccessor.OnWritingAsync(leftResourceTracked, WriteOperationKind.RemoveFromRelationship, cancellationToken); @@ -551,11 +571,42 @@ private void MarkRelationshipAsLoaded(TResource leftResource, RelationshipAttrib EntityEntry leftEntry = _dbContext.Entry(leftResource); CollectionEntry rightCollectionEntry = leftEntry.Collection(relationship.Property.Name); rightCollectionEntry.IsLoaded = true; + + if (rightCollectionEntry.Metadata is ISkipNavigation skipNavigation) + { + MarkManyToManyRelationshipAsLoaded(leftEntry, skipNavigation); + } + } + + private void MarkManyToManyRelationshipAsLoaded(EntityEntry leftEntry, ISkipNavigation skipNavigation) + { + string[] primaryKeyNames = skipNavigation.ForeignKey.PrincipalKey.Properties.Select(property => property.Name).ToArray(); + object?[] primaryKeyValues = GetCurrentKeyValues(leftEntry, primaryKeyNames); + + string[] foreignKeyNames = skipNavigation.ForeignKey.Properties.Select(property => property.Name).ToArray(); + + foreach (EntityEntry joinEntry in _dbContext.ChangeTracker.Entries().Where(entry => entry.Metadata == skipNavigation.JoinEntityType).ToArray()) + { + object?[] foreignKeyValues = GetCurrentKeyValues(joinEntry, foreignKeyNames); + + if (primaryKeyValues.SequenceEqual(foreignKeyValues)) + { + joinEntry.State = EntityState.Unchanged; + } + } + } + + private static object?[] GetCurrentKeyValues(EntityEntry entry, IEnumerable keyNames) + { + return keyNames.Select(keyName => entry.Property(keyName).CurrentValue).ToArray(); } protected async Task UpdateRelationshipAsync(RelationshipAttribute relationship, TResource leftResource, object? valueToAssign, CancellationToken cancellationToken) { + ArgumentNullException.ThrowIfNull(relationship); + ArgumentNullException.ThrowIfNull(leftResource); + object? trackedValueToAssign = EnsureRelationshipValueToAssignIsTracked(valueToAssign, relationship.Property.PropertyType); if (RequireLoadOfInverseRelationship(relationship, trackedValueToAssign)) @@ -576,18 +627,29 @@ protected async Task UpdateRelationshipAsync(RelationshipAttribute relationship, return null; } - IReadOnlyCollection rightResources = _collectionConverter.ExtractResources(rightValue); - IIdentifiable[] rightResourcesTracked = rightResources.Select(rightResource => _dbContext.GetTrackedOrAttach(rightResource)).ToArray(); + IReadOnlyCollection rightResources = CollectionConverter.Instance.ExtractResources(rightValue); + IIdentifiable[] rightResourcesTracked = rightResources.Select(_dbContext.GetTrackedOrAttach).ToArray(); return rightValue is IEnumerable - ? _collectionConverter.CopyToTypedCollection(rightResourcesTracked, relationshipPropertyType) + ? CollectionConverter.Instance.CopyToTypedCollection(rightResourcesTracked, relationshipPropertyType) : rightResourcesTracked.Single(); } private bool RequireLoadOfInverseRelationship(RelationshipAttribute relationship, [NotNullWhen(true)] object? trackedValueToAssign) { // See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/502. - return trackedValueToAssign != null && relationship is HasOneAttribute { IsOneToOne: true }; + if (trackedValueToAssign != null && relationship is HasOneAttribute { IsOneToOne: true }) + { + IEntityType? leftEntityType = _dbContext.Model.FindEntityType(relationship.LeftType.ClrType); + INavigation? navigation = leftEntityType?.FindNavigation(relationship.Property.Name); + + if (HasForeignKeyAtLeftSide(relationship, navigation)) + { + return true; + } + } + + return false; } protected virtual async Task SaveChangesAsync(CancellationToken cancellationToken) diff --git a/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs index 26b7c4513e..218a09cb93 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs @@ -14,6 +14,4 @@ namespace JsonApiDotNetCore.Repositories; /// [PublicAPI] public interface IResourceRepository : IResourceReadRepository, IResourceWriteRepository - where TResource : class, IIdentifiable -{ -} + where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs index 149fef1cfb..df08f04f26 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; @@ -10,6 +11,11 @@ namespace JsonApiDotNetCore.Repositories; /// public interface IResourceRepositoryAccessor { + /// + /// Uses the to lookup the corresponding for the specified CLR type. + /// + ResourceType LookupResourceType(Type resourceClrType); + /// /// Invokes for the specified resource type. /// @@ -29,7 +35,7 @@ Task> GetAsync(QueryLayer queryLayer, /// /// Invokes for the specified resource type. /// - Task GetForCreateAsync(Type resourceClrType, TId id, CancellationToken cancellationToken) + Task GetForCreateAsync(Type resourceClrType, [DisallowNull] TId id, CancellationToken cancellationToken) where TResource : class, IIdentifiable; /// @@ -53,7 +59,7 @@ Task UpdateAsync(TResource resourceFromRequest, TResource resourceFro /// /// Invokes for the specified resource type. /// - Task DeleteAsync(TResource? resourceFromDatabase, TId id, CancellationToken cancellationToken) + Task DeleteAsync(TResource? resourceFromDatabase, [DisallowNull] TId id, CancellationToken cancellationToken) where TResource : class, IIdentifiable; /// @@ -65,7 +71,7 @@ Task SetRelationshipAsync(TResource leftResource, object? rightValue, /// /// Invokes for the specified resource type. /// - Task AddToToManyRelationshipAsync(TResource? leftResource, TId leftId, ISet rightResourceIds, + Task AddToToManyRelationshipAsync(TResource? leftResource, [DisallowNull] TId leftId, ISet rightResourceIds, CancellationToken cancellationToken) where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs index fb0267d18a..49d2c60d73 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using JetBrains.Annotations; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Resources; @@ -23,7 +24,7 @@ public interface IResourceWriteRepository /// /// This method can be overridden to assign resource-specific required relationships. /// - Task GetForCreateAsync(Type resourceClrType, TId id, CancellationToken cancellationToken); + Task GetForCreateAsync(Type resourceClrType, [DisallowNull] TId id, CancellationToken cancellationToken); /// /// Creates a new resource in the underlying data store. @@ -43,7 +44,7 @@ public interface IResourceWriteRepository /// /// Deletes an existing resource from the underlying data store. /// - Task DeleteAsync(TResource? resourceFromDatabase, TId id, CancellationToken cancellationToken); + Task DeleteAsync(TResource? resourceFromDatabase, [DisallowNull] TId id, CancellationToken cancellationToken); /// /// Performs a complete replacement of the relationship in the underlying data store. @@ -53,7 +54,8 @@ public interface IResourceWriteRepository /// /// Adds resources to a to-many relationship in the underlying data store. /// - Task AddToToManyRelationshipAsync(TResource? leftResource, TId leftId, ISet rightResourceIds, CancellationToken cancellationToken); + Task AddToToManyRelationshipAsync(TResource? leftResource, [DisallowNull] TId leftId, ISet rightResourceIds, + CancellationToken cancellationToken); /// /// Removes resources from a to-many relationship in the underlying data store. diff --git a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs index 5f00cdf08d..4e3b5163a3 100644 --- a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; @@ -9,7 +10,7 @@ namespace JsonApiDotNetCore.Repositories; -/// +/// [PublicAPI] public class ResourceRepositoryAccessor : IResourceRepositoryAccessor { @@ -19,19 +20,29 @@ public class ResourceRepositoryAccessor : IResourceRepositoryAccessor public ResourceRepositoryAccessor(IServiceProvider serviceProvider, IResourceGraph resourceGraph, IJsonApiRequest request) { - ArgumentGuard.NotNull(serviceProvider, nameof(serviceProvider)); - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - ArgumentGuard.NotNull(request, nameof(request)); + ArgumentNullException.ThrowIfNull(serviceProvider); + ArgumentNullException.ThrowIfNull(resourceGraph); + ArgumentNullException.ThrowIfNull(request); _serviceProvider = serviceProvider; _resourceGraph = resourceGraph; _request = request; } + /// + public ResourceType LookupResourceType(Type resourceClrType) + { + ArgumentNullException.ThrowIfNull(resourceClrType); + + return _resourceGraph.GetResourceType(resourceClrType); + } + /// public async Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) where TResource : class, IIdentifiable { + ArgumentNullException.ThrowIfNull(queryLayer); + dynamic repository = ResolveReadRepository(typeof(TResource)); return (IReadOnlyCollection)await repository.GetAsync(queryLayer, cancellationToken); } @@ -39,7 +50,8 @@ public async Task> GetAsync(QueryLayer /// public async Task> GetAsync(ResourceType resourceType, QueryLayer queryLayer, CancellationToken cancellationToken) { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(queryLayer); dynamic repository = ResolveReadRepository(resourceType); return (IReadOnlyCollection)await repository.GetAsync(queryLayer, cancellationToken); @@ -48,14 +60,18 @@ public async Task> GetAsync(ResourceType reso /// public async Task CountAsync(ResourceType resourceType, FilterExpression? filter, CancellationToken cancellationToken) { + ArgumentNullException.ThrowIfNull(resourceType); + dynamic repository = ResolveReadRepository(resourceType); return (int)await repository.CountAsync(filter, cancellationToken); } /// - public async Task GetForCreateAsync(Type resourceClrType, TId id, CancellationToken cancellationToken) + public async Task GetForCreateAsync(Type resourceClrType, [DisallowNull] TId id, CancellationToken cancellationToken) where TResource : class, IIdentifiable { + ArgumentNullException.ThrowIfNull(resourceClrType); + dynamic repository = GetWriteRepository(typeof(TResource)); return await repository.GetForCreateAsync(resourceClrType, id, cancellationToken); } @@ -64,6 +80,9 @@ public async Task GetForCreateAsync(Type resourceClrT public async Task CreateAsync(TResource resourceFromRequest, TResource resourceForDatabase, CancellationToken cancellationToken) where TResource : class, IIdentifiable { + ArgumentNullException.ThrowIfNull(resourceFromRequest); + ArgumentNullException.ThrowIfNull(resourceForDatabase); + dynamic repository = GetWriteRepository(typeof(TResource)); await repository.CreateAsync(resourceFromRequest, resourceForDatabase, cancellationToken); } @@ -72,6 +91,8 @@ public async Task CreateAsync(TResource resourceFromRequest, TResourc public async Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) where TResource : class, IIdentifiable { + ArgumentNullException.ThrowIfNull(queryLayer); + dynamic repository = GetWriteRepository(typeof(TResource)); return await repository.GetForUpdateAsync(queryLayer, cancellationToken); } @@ -80,12 +101,15 @@ public async Task CreateAsync(TResource resourceFromRequest, TResourc public async Task UpdateAsync(TResource resourceFromRequest, TResource resourceFromDatabase, CancellationToken cancellationToken) where TResource : class, IIdentifiable { + ArgumentNullException.ThrowIfNull(resourceFromRequest); + ArgumentNullException.ThrowIfNull(resourceFromDatabase); + dynamic repository = GetWriteRepository(typeof(TResource)); await repository.UpdateAsync(resourceFromRequest, resourceFromDatabase, cancellationToken); } /// - public async Task DeleteAsync(TResource? resourceFromDatabase, TId id, CancellationToken cancellationToken) + public async Task DeleteAsync(TResource? resourceFromDatabase, [DisallowNull] TId id, CancellationToken cancellationToken) where TResource : class, IIdentifiable { dynamic repository = GetWriteRepository(typeof(TResource)); @@ -96,15 +120,19 @@ public async Task DeleteAsync(TResource? resourceFromDatabase, T public async Task SetRelationshipAsync(TResource leftResource, object? rightValue, CancellationToken cancellationToken) where TResource : class, IIdentifiable { + ArgumentNullException.ThrowIfNull(leftResource); + dynamic repository = GetWriteRepository(typeof(TResource)); await repository.SetRelationshipAsync(leftResource, rightValue, cancellationToken); } /// - public async Task AddToToManyRelationshipAsync(TResource? leftResource, TId leftId, ISet rightResourceIds, + public async Task AddToToManyRelationshipAsync(TResource? leftResource, [DisallowNull] TId leftId, ISet rightResourceIds, CancellationToken cancellationToken) where TResource : class, IIdentifiable { + ArgumentNullException.ThrowIfNull(rightResourceIds); + dynamic repository = GetWriteRepository(typeof(TResource)); await repository.AddToToManyRelationshipAsync(leftResource, leftId, rightResourceIds, cancellationToken); } @@ -114,20 +142,27 @@ public async Task RemoveFromToManyRelationshipAsync(TResource leftRes CancellationToken cancellationToken) where TResource : class, IIdentifiable { + ArgumentNullException.ThrowIfNull(leftResource); + ArgumentNullException.ThrowIfNull(rightResourceIds); + dynamic repository = GetWriteRepository(typeof(TResource)); await repository.RemoveFromToManyRelationshipAsync(leftResource, rightResourceIds, cancellationToken); } protected object ResolveReadRepository(Type resourceClrType) { + ArgumentNullException.ThrowIfNull(resourceClrType); + ResourceType resourceType = _resourceGraph.GetResourceType(resourceClrType); return ResolveReadRepository(resourceType); } protected virtual object ResolveReadRepository(ResourceType resourceType) { - Type resourceDefinitionType = typeof(IResourceReadRepository<,>).MakeGenericType(resourceType.ClrType, resourceType.IdentityClrType); - return _serviceProvider.GetRequiredService(resourceDefinitionType); + ArgumentNullException.ThrowIfNull(resourceType); + + Type repositoryType = typeof(IResourceReadRepository<,>).MakeGenericType(resourceType.ClrType, resourceType.IdentityClrType); + return _serviceProvider.GetRequiredService(repositoryType); } private object GetWriteRepository(Type resourceClrType) @@ -153,6 +188,8 @@ private object GetWriteRepository(Type resourceClrType) protected virtual object ResolveWriteRepository(Type resourceClrType) { + ArgumentNullException.ThrowIfNull(resourceClrType); + ResourceType resourceType = _resourceGraph.GetResourceType(resourceClrType); Type resourceDefinitionType = typeof(IResourceWriteRepository<,>).MakeGenericType(resourceType.ClrType, resourceType.IdentityClrType); diff --git a/src/JsonApiDotNetCore/Resources/AbstractResourceWrapper.cs b/src/JsonApiDotNetCore/Resources/AbstractResourceWrapper.cs index 0d294feb57..3cf6233cf2 100644 --- a/src/JsonApiDotNetCore/Resources/AbstractResourceWrapper.cs +++ b/src/JsonApiDotNetCore/Resources/AbstractResourceWrapper.cs @@ -8,7 +8,7 @@ internal sealed class AbstractResourceWrapper : Identifiable, IAbstrac public AbstractResourceWrapper(Type abstractType) { - ArgumentGuard.NotNull(abstractType, nameof(abstractType)); + ArgumentNullException.ThrowIfNull(abstractType); AbstractType = abstractType; } diff --git a/src/JsonApiDotNetCore/Resources/Annotations/CapabilitiesExtensions.cs b/src/JsonApiDotNetCore/Resources/Annotations/CapabilitiesExtensions.cs new file mode 100644 index 0000000000..84352f2206 --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/Annotations/CapabilitiesExtensions.cs @@ -0,0 +1,45 @@ +namespace JsonApiDotNetCore.Resources.Annotations; + +internal static class CapabilitiesExtensions +{ + public static bool IsViewBlocked(this ResourceFieldAttribute field) + { + return field switch + { + AttrAttribute attrAttribute => !attrAttribute.Capabilities.HasFlag(AttrCapabilities.AllowView), + HasOneAttribute hasOneRelationship => !hasOneRelationship.Capabilities.HasFlag(HasOneCapabilities.AllowView), + HasManyAttribute hasManyRelationship => !hasManyRelationship.Capabilities.HasFlag(HasManyCapabilities.AllowView), + _ => false + }; + } + + public static bool IsIncludeBlocked(this RelationshipAttribute relationship) + { + return relationship switch + { + HasOneAttribute hasOneRelationship => !hasOneRelationship.Capabilities.HasFlag(HasOneCapabilities.AllowInclude), + HasManyAttribute hasManyRelationship => !hasManyRelationship.Capabilities.HasFlag(HasManyCapabilities.AllowInclude), + _ => false + }; + } + + public static bool IsFilterBlocked(this ResourceFieldAttribute field) + { + return field switch + { + AttrAttribute attrAttribute => !attrAttribute.Capabilities.HasFlag(AttrCapabilities.AllowFilter), + HasManyAttribute hasManyRelationship => !hasManyRelationship.Capabilities.HasFlag(HasManyCapabilities.AllowFilter), + _ => false + }; + } + + public static bool IsSetBlocked(this RelationshipAttribute relationship) + { + return relationship switch + { + HasOneAttribute hasOneRelationship => !hasOneRelationship.Capabilities.HasFlag(HasOneCapabilities.AllowSet), + HasManyAttribute hasManyRelationship => !hasManyRelationship.Capabilities.HasFlag(HasManyCapabilities.AllowSet), + _ => false + }; + } +} diff --git a/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs index 1f69735f92..13404f2759 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs @@ -3,6 +3,9 @@ namespace JsonApiDotNetCore.Resources; /// /// Used to determine whether additional changes to a resource (side effects), not specified in a POST or PATCH request, have been applied. /// +/// +/// The resource type. +/// public interface IResourceChangeTracker where TResource : class, IIdentifiable { diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs index 9af79831b2..41d366b5c3 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs @@ -60,7 +60,7 @@ public interface IResourceDefinition /// An optional existing pagination, coming from query string. Can be null. /// /// - /// The changed pagination, or null to use the first page with default size from options. To disable paging, set + /// The changed pagination, or null to use the first page with default size from options. To disable pagination, set /// to null. /// PaginationExpression? OnApplyPagination(PaginationExpression? existingPagination); @@ -241,9 +241,9 @@ Task OnAddToRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRe /// /// /// - /// The original resource as retrieved from the underlying data store. The indication "left" specifies that is - /// declared on . Be aware that for performance reasons, not the full relationship is populated, but only the subset of - /// resources to be removed. + /// Identifier of the left resource. The indication "left" specifies that is declared on + /// . In contrast to other relationship methods, only the left ID and only the subset of right resources to be removed + /// are retrieved from the underlying data store. /// /// /// The to-many relationship being removed from. diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs index 55f32ead40..d16a6074b2 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs @@ -2,6 +2,7 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.QueryableBuilding; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.Resources; @@ -11,6 +12,24 @@ namespace JsonApiDotNetCore.Resources; /// public interface IResourceDefinitionAccessor { + /// + /// Indicates whether this request targets only fetching of data (resources and relationships), as opposed to applying changes. + /// + /// + /// This property was added to reduce the impact of taking a breaking change. It will likely be removed in the next major version. + /// + [Obsolete("Use IJsonApiRequest.IsReadOnly.")] + bool IsReadOnlyRequest { get; } + + /// + /// Gets an instance from the service container. + /// + /// + /// This property was added to reduce the impact of taking a breaking change. It will likely be removed in the next major version. + /// + [Obsolete("Use injected IQueryableBuilder instead.")] + public IQueryableBuilder QueryableBuilder { get; } + /// /// Invokes for the specified resource type. /// diff --git a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs index 8c9d4b6a36..d4e3c156cb 100644 --- a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs +++ b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs @@ -1,5 +1,4 @@ using System.Reflection; -using JsonApiDotNetCore.Resources.Internal; namespace JsonApiDotNetCore.Resources; @@ -9,7 +8,7 @@ internal static class IdentifiableExtensions public static object GetTypedId(this IIdentifiable identifiable) { - ArgumentGuard.NotNull(identifiable, nameof(identifiable)); + ArgumentNullException.ThrowIfNull(identifiable); PropertyInfo? property = identifiable.GetClrType().GetProperty(IdPropertyName); @@ -19,17 +18,12 @@ public static object GetTypedId(this IIdentifiable identifiable) } object? propertyValue = property.GetValue(identifiable); + object? defaultValue = RuntimeTypeConverter.GetDefaultValue(property.PropertyType); - // PERF: We want to throw when 'Id' is unassigned without doing an expensive reflection call, unless this is likely the case. - if (identifiable.StringId == null) + if (Equals(propertyValue, defaultValue)) { - object? defaultValue = RuntimeTypeConverter.GetDefaultValue(property.PropertyType); - - if (Equals(propertyValue, defaultValue)) - { - throw new InvalidOperationException($"Property '{identifiable.GetClrType().Name}.{IdPropertyName}' should " + - $"have been assigned at this point, but it contains its default {property.PropertyType.Name} value '{propertyValue}'."); - } + throw new InvalidOperationException($"Property '{identifiable.GetClrType().Name}.{IdPropertyName}' should " + + $"have been assigned at this point, but it contains its default {property.PropertyType.Name} value '{propertyValue}'."); } return propertyValue!; @@ -37,7 +31,7 @@ public static object GetTypedId(this IIdentifiable identifiable) public static Type GetClrType(this IIdentifiable identifiable) { - ArgumentGuard.NotNull(identifiable, nameof(identifiable)); + ArgumentNullException.ThrowIfNull(identifiable); return identifiable is IAbstractResourceWrapper abstractResource ? abstractResource.AbstractType : identifiable.GetType(); } diff --git a/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs index dbb90bf6fe..e2a354ca76 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 @@ -26,7 +26,7 @@ public class JsonApiResourceDefinition : IResourceDefinition(); @@ -63,9 +63,9 @@ public virtual IImmutableSet OnApplyIncludes(IImmutabl /// }); /// ]]> /// - protected SortExpression CreateSortExpressionFromLambda(PropertySortOrder keySelectors) + protected virtual SortExpression CreateSortExpressionFromLambda(PropertySortOrder keySelectors) { - ArgumentGuard.NotNullNorEmpty(keySelectors, nameof(keySelectors)); + ArgumentGuard.NotNullNorEmpty(keySelectors); ImmutableArray.Builder elementsBuilder = ImmutableArray.CreateBuilder(keySelectors.Count); var lambdaConverter = new SortExpressionLambdaConverter(ResourceGraph); @@ -177,7 +177,5 @@ public virtual void OnSerialize(TResource resource) /// This is an alias type intended to simplify the implementation's method signature. See for usage /// details. /// - public sealed class PropertySortOrder : List<(Expression> KeySelector, ListSortDirection SortDirection)> - { - } + public sealed class PropertySortOrder : List<(Expression> KeySelector, ListSortDirection SortDirection)>; } diff --git a/src/JsonApiDotNetCore/Resources/OperationContainer.cs b/src/JsonApiDotNetCore/Resources/OperationContainer.cs index d2fa2c0d3e..dd2cfe2630 100644 --- a/src/JsonApiDotNetCore/Resources/OperationContainer.cs +++ b/src/JsonApiDotNetCore/Resources/OperationContainer.cs @@ -10,17 +10,15 @@ namespace JsonApiDotNetCore.Resources; [PublicAPI] public sealed class OperationContainer { - private static readonly CollectionConverter CollectionConverter = new(); - public IIdentifiable Resource { get; } public ITargetedFields TargetedFields { get; } public IJsonApiRequest Request { get; } public OperationContainer(IIdentifiable resource, ITargetedFields targetedFields, IJsonApiRequest request) { - ArgumentGuard.NotNull(resource, nameof(resource)); - ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); - ArgumentGuard.NotNull(request, nameof(request)); + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(targetedFields); + ArgumentNullException.ThrowIfNull(request); Resource = resource; TargetedFields = targetedFields; @@ -34,7 +32,7 @@ public void SetTransactionId(string transactionId) public OperationContainer WithResource(IIdentifiable resource) { - ArgumentGuard.NotNull(resource, nameof(resource)); + ArgumentNullException.ThrowIfNull(resource); return new OperationContainer(resource, TargetedFields, Request); } @@ -56,8 +54,8 @@ public ISet GetSecondaryResources() private void AddSecondaryResources(RelationshipAttribute relationship, HashSet secondaryResources) { object? rightValue = relationship.GetValue(Resource); - IReadOnlyCollection rightResources = CollectionConverter.ExtractResources(rightValue); + IReadOnlyCollection rightResources = CollectionConverter.Instance.ExtractResources(rightValue); - secondaryResources.AddRange(rightResources); + secondaryResources.UnionWith(rightResources); } } diff --git a/src/JsonApiDotNetCore/Resources/QueryStringParameterHandlers.cs b/src/JsonApiDotNetCore/Resources/QueryStringParameterHandlers.cs index 52c9f7d71a..7fe8970b53 100644 --- a/src/JsonApiDotNetCore/Resources/QueryStringParameterHandlers.cs +++ b/src/JsonApiDotNetCore/Resources/QueryStringParameterHandlers.cs @@ -6,6 +6,7 @@ namespace JsonApiDotNetCore.Resources; /// This is an alias type intended to simplify the implementation's method signature. See /// for usage details. /// -public sealed class QueryStringParameterHandlers : Dictionary, StringValues, IQueryable>> -{ -} +/// +/// The resource type. +/// +public sealed class QueryStringParameterHandlers : Dictionary, StringValues, IQueryable>>; diff --git a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs index 658e5e2c5b..c17ce60b45 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs @@ -1,11 +1,10 @@ -using System.Text.Json; using JetBrains.Annotations; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCore.Resources; -/// +/// [PublicAPI] public sealed class ResourceChangeTracker : IResourceChangeTracker where TResource : class, IIdentifiable @@ -13,14 +12,14 @@ public sealed class ResourceChangeTracker : IResourceChangeTracker? _initiallyStoredAttributeValues; - private IDictionary? _requestAttributeValues; - private IDictionary? _finallyStoredAttributeValues; + private Dictionary? _initiallyStoredAttributeValues; + private Dictionary? _requestAttributeValues; + private Dictionary? _finallyStoredAttributeValues; public ResourceChangeTracker(IJsonApiRequest request, ITargetedFields targetedFields) { - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(targetedFields); _request = request; _targetedFields = targetedFields; @@ -29,7 +28,7 @@ public ResourceChangeTracker(IJsonApiRequest request, ITargetedFields targetedFi /// public void SetInitiallyStoredAttributeValues(TResource resource) { - ArgumentGuard.NotNull(resource, nameof(resource)); + ArgumentNullException.ThrowIfNull(resource); _initiallyStoredAttributeValues = CreateAttributeDictionary(resource, _request.PrimaryResourceType!.Attributes); } @@ -37,7 +36,7 @@ public void SetInitiallyStoredAttributeValues(TResource resource) /// public void SetRequestAttributeValues(TResource resource) { - ArgumentGuard.NotNull(resource, nameof(resource)); + ArgumentNullException.ThrowIfNull(resource); _requestAttributeValues = CreateAttributeDictionary(resource, _targetedFields.Attributes); } @@ -45,20 +44,19 @@ public void SetRequestAttributeValues(TResource resource) /// public void SetFinallyStoredAttributeValues(TResource resource) { - ArgumentGuard.NotNull(resource, nameof(resource)); + ArgumentNullException.ThrowIfNull(resource); _finallyStoredAttributeValues = CreateAttributeDictionary(resource, _request.PrimaryResourceType!.Attributes); } - private IDictionary CreateAttributeDictionary(TResource resource, IEnumerable attributes) + private Dictionary CreateAttributeDictionary(TResource resource, IEnumerable attributes) { - var result = new Dictionary(); + var result = new Dictionary(); foreach (AttrAttribute attribute in attributes) { object? value = attribute.GetValue(resource); - string json = JsonSerializer.Serialize(value); - result.Add(attribute.PublicName, json); + result.Add(attribute.PublicName, value); } return result; @@ -71,21 +69,21 @@ public bool HasImplicitChanges() { foreach (string key in _initiallyStoredAttributeValues.Keys) { - if (_requestAttributeValues.TryGetValue(key, out string? requestValue)) + if (_requestAttributeValues.TryGetValue(key, out object? requestValue)) { - string actualValue = _finallyStoredAttributeValues[key]; + object? actualValue = _finallyStoredAttributeValues[key]; - if (requestValue != actualValue) + if (!Equals(requestValue, actualValue)) { return true; } } else { - string initiallyStoredValue = _initiallyStoredAttributeValues[key]; - string finallyStoredValue = _finallyStoredAttributeValues[key]; + object? initiallyStoredValue = _initiallyStoredAttributeValues[key]; + object? finallyStoredValue = _finallyStoredAttributeValues[key]; - if (initiallyStoredValue != finallyStoredValue) + if (!Equals(initiallyStoredValue, finallyStoredValue)) { return true; } diff --git a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs index 9f8dfdddeb..a225766fb5 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs @@ -3,22 +3,38 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.QueryableBuilding; using JsonApiDotNetCore.Resources.Annotations; using Microsoft.Extensions.DependencyInjection; namespace JsonApiDotNetCore.Resources; -/// +/// [PublicAPI] public class ResourceDefinitionAccessor : IResourceDefinitionAccessor { private readonly IResourceGraph _resourceGraph; private readonly IServiceProvider _serviceProvider; + /// + [Obsolete("Use IJsonApiRequest.IsReadOnly.")] + public bool IsReadOnlyRequest + { + get + { + var request = _serviceProvider.GetRequiredService(); + return request.IsReadOnly; + } + } + + /// + [Obsolete("Use injected IQueryableBuilder instead.")] + public IQueryableBuilder QueryableBuilder => _serviceProvider.GetRequiredService(); + public ResourceDefinitionAccessor(IResourceGraph resourceGraph, IServiceProvider serviceProvider) { - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - ArgumentGuard.NotNull(serviceProvider, nameof(serviceProvider)); + ArgumentNullException.ThrowIfNull(resourceGraph); + ArgumentNullException.ThrowIfNull(serviceProvider); _resourceGraph = resourceGraph; _serviceProvider = serviceProvider; @@ -27,7 +43,7 @@ public ResourceDefinitionAccessor(IResourceGraph resourceGraph, IServiceProvider /// public IImmutableSet OnApplyIncludes(ResourceType resourceType, IImmutableSet existingIncludes) { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + ArgumentNullException.ThrowIfNull(resourceType); dynamic resourceDefinition = ResolveResourceDefinition(resourceType); return resourceDefinition.OnApplyIncludes(existingIncludes); @@ -36,7 +52,7 @@ public IImmutableSet OnApplyIncludes(ResourceType reso /// public FilterExpression? OnApplyFilter(ResourceType resourceType, FilterExpression? existingFilter) { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + ArgumentNullException.ThrowIfNull(resourceType); dynamic resourceDefinition = ResolveResourceDefinition(resourceType); return resourceDefinition.OnApplyFilter(existingFilter); @@ -45,7 +61,7 @@ public IImmutableSet OnApplyIncludes(ResourceType reso /// public SortExpression? OnApplySort(ResourceType resourceType, SortExpression? existingSort) { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + ArgumentNullException.ThrowIfNull(resourceType); dynamic resourceDefinition = ResolveResourceDefinition(resourceType); return resourceDefinition.OnApplySort(existingSort); @@ -54,7 +70,7 @@ public IImmutableSet OnApplyIncludes(ResourceType reso /// public PaginationExpression? OnApplyPagination(ResourceType resourceType, PaginationExpression? existingPagination) { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + ArgumentNullException.ThrowIfNull(resourceType); dynamic resourceDefinition = ResolveResourceDefinition(resourceType); return resourceDefinition.OnApplyPagination(existingPagination); @@ -63,7 +79,7 @@ public IImmutableSet OnApplyIncludes(ResourceType reso /// public SparseFieldSetExpression? OnApplySparseFieldSet(ResourceType resourceType, SparseFieldSetExpression? existingSparseFieldSet) { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + ArgumentNullException.ThrowIfNull(resourceType); dynamic resourceDefinition = ResolveResourceDefinition(resourceType); return resourceDefinition.OnApplySparseFieldSet(existingSparseFieldSet); @@ -72,11 +88,11 @@ public IImmutableSet OnApplyIncludes(ResourceType reso /// public object? GetQueryableHandlerForQueryStringParameter(Type resourceClrType, string parameterName) { - ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); - ArgumentGuard.NotNullNorEmpty(parameterName, nameof(parameterName)); + ArgumentNullException.ThrowIfNull(resourceClrType); + ArgumentException.ThrowIfNullOrEmpty(parameterName); dynamic resourceDefinition = ResolveResourceDefinition(resourceClrType); - dynamic handlers = resourceDefinition.OnRegisterQueryableHandlersForQueryStringParameters(); + dynamic? handlers = resourceDefinition.OnRegisterQueryableHandlersForQueryStringParameters(); if (handlers != null) { @@ -92,7 +108,7 @@ public IImmutableSet OnApplyIncludes(ResourceType reso /// public IDictionary? GetMeta(ResourceType resourceType, IIdentifiable resourceInstance) { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + ArgumentNullException.ThrowIfNull(resourceType); dynamic resourceDefinition = ResolveResourceDefinition(resourceType); return resourceDefinition.GetMeta((dynamic)resourceInstance); @@ -102,7 +118,7 @@ public IImmutableSet OnApplyIncludes(ResourceType reso public async Task OnPrepareWriteAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) where TResource : class, IIdentifiable { - ArgumentGuard.NotNull(resource, nameof(resource)); + ArgumentNullException.ThrowIfNull(resource); dynamic resourceDefinition = ResolveResourceDefinition(resource.GetClrType()); await resourceDefinition.OnPrepareWriteAsync((dynamic)resource, writeOperation, cancellationToken); @@ -113,8 +129,8 @@ public async Task OnPrepareWriteAsync(TResource resource, WriteOperat IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) where TResource : class, IIdentifiable { - ArgumentGuard.NotNull(leftResource, nameof(leftResource)); - ArgumentGuard.NotNull(hasOneRelationship, nameof(hasOneRelationship)); + ArgumentNullException.ThrowIfNull(leftResource); + ArgumentNullException.ThrowIfNull(hasOneRelationship); dynamic resourceDefinition = ResolveResourceDefinition(leftResource.GetClrType()); @@ -127,9 +143,9 @@ public async Task OnSetToManyRelationshipAsync(TResource leftResource ISet rightResourceIds, WriteOperationKind writeOperation, CancellationToken cancellationToken) where TResource : class, IIdentifiable { - ArgumentGuard.NotNull(leftResource, nameof(leftResource)); - ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship)); - ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); + ArgumentNullException.ThrowIfNull(leftResource); + ArgumentNullException.ThrowIfNull(hasManyRelationship); + ArgumentNullException.ThrowIfNull(rightResourceIds); dynamic resourceDefinition = ResolveResourceDefinition(leftResource.GetClrType()); await resourceDefinition.OnSetToManyRelationshipAsync((dynamic)leftResource, hasManyRelationship, rightResourceIds, writeOperation, cancellationToken); @@ -140,8 +156,8 @@ public async Task OnAddToRelationshipAsync(TResource leftResource, Ha CancellationToken cancellationToken) where TResource : class, IIdentifiable { - ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship)); - ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); + ArgumentNullException.ThrowIfNull(hasManyRelationship); + ArgumentNullException.ThrowIfNull(rightResourceIds); dynamic resourceDefinition = ResolveResourceDefinition(leftResource.GetClrType()); await resourceDefinition.OnAddToRelationshipAsync((dynamic)leftResource, hasManyRelationship, rightResourceIds, cancellationToken); @@ -152,9 +168,9 @@ public async Task OnRemoveFromRelationshipAsync(TResource leftResourc ISet rightResourceIds, CancellationToken cancellationToken) where TResource : class, IIdentifiable { - ArgumentGuard.NotNull(leftResource, nameof(leftResource)); - ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship)); - ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); + ArgumentNullException.ThrowIfNull(leftResource); + ArgumentNullException.ThrowIfNull(hasManyRelationship); + ArgumentNullException.ThrowIfNull(rightResourceIds); dynamic resourceDefinition = ResolveResourceDefinition(leftResource.GetClrType()); await resourceDefinition.OnRemoveFromRelationshipAsync((dynamic)leftResource, hasManyRelationship, rightResourceIds, cancellationToken); @@ -164,7 +180,7 @@ public async Task OnRemoveFromRelationshipAsync(TResource leftResourc public async Task OnWritingAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) where TResource : class, IIdentifiable { - ArgumentGuard.NotNull(resource, nameof(resource)); + ArgumentNullException.ThrowIfNull(resource); dynamic resourceDefinition = ResolveResourceDefinition(resource.GetClrType()); await resourceDefinition.OnWritingAsync((dynamic)resource, writeOperation, cancellationToken); @@ -174,7 +190,7 @@ public async Task OnWritingAsync(TResource resource, WriteOperationKi public async Task OnWriteSucceededAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) where TResource : class, IIdentifiable { - ArgumentGuard.NotNull(resource, nameof(resource)); + ArgumentNullException.ThrowIfNull(resource); dynamic resourceDefinition = ResolveResourceDefinition(resource.GetClrType()); await resourceDefinition.OnWriteSucceededAsync((dynamic)resource, writeOperation, cancellationToken); @@ -183,7 +199,7 @@ public async Task OnWriteSucceededAsync(TResource resource, WriteOper /// public void OnDeserialize(IIdentifiable resource) { - ArgumentGuard.NotNull(resource, nameof(resource)); + ArgumentNullException.ThrowIfNull(resource); dynamic resourceDefinition = ResolveResourceDefinition(resource.GetClrType()); resourceDefinition.OnDeserialize((dynamic)resource); @@ -192,7 +208,7 @@ public void OnDeserialize(IIdentifiable resource) /// public void OnSerialize(IIdentifiable resource) { - ArgumentGuard.NotNull(resource, nameof(resource)); + ArgumentNullException.ThrowIfNull(resource); dynamic resourceDefinition = ResolveResourceDefinition(resource.GetClrType()); resourceDefinition.OnSerialize((dynamic)resource); @@ -200,12 +216,16 @@ public void OnSerialize(IIdentifiable resource) protected object ResolveResourceDefinition(Type resourceClrType) { + ArgumentNullException.ThrowIfNull(resourceClrType); + ResourceType resourceType = _resourceGraph.GetResourceType(resourceClrType); return ResolveResourceDefinition(resourceType); } protected virtual object ResolveResourceDefinition(ResourceType resourceType) { + ArgumentNullException.ThrowIfNull(resourceType); + Type resourceDefinitionType = typeof(IResourceDefinition<,>).MakeGenericType(resourceType.ClrType, resourceType.IdentityClrType); return _serviceProvider.GetRequiredService(resourceDefinitionType); } diff --git a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs index c276c073cd..5a18763939 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs @@ -1,12 +1,12 @@ using System.Linq.Expressions; using System.Reflection; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Internal; +using JsonApiDotNetCore.Queries; using Microsoft.Extensions.DependencyInjection; namespace JsonApiDotNetCore.Resources; -/// +/// internal sealed class ResourceFactory : IResourceFactory { private static readonly TypeLocator TypeLocator = new(); @@ -15,7 +15,7 @@ internal sealed class ResourceFactory : IResourceFactory public ResourceFactory(IServiceProvider serviceProvider) { - ArgumentGuard.NotNull(serviceProvider, nameof(serviceProvider)); + ArgumentNullException.ThrowIfNull(serviceProvider); _serviceProvider = serviceProvider; } @@ -23,7 +23,7 @@ public ResourceFactory(IServiceProvider serviceProvider) /// public IIdentifiable CreateInstance(Type resourceClrType) { - ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); + ArgumentNullException.ThrowIfNull(resourceClrType); if (!resourceClrType.IsAssignableTo(typeof(IIdentifiable))) { @@ -50,7 +50,7 @@ private static IIdentifiable CreateWrapperForAbstractType(Type resourceClrType) Type wrapperClrType = typeof(AbstractResourceWrapper<>).MakeGenericType(descriptor.IdClrType); ConstructorInfo constructor = wrapperClrType.GetConstructors().Single(); - object resource = constructor.Invoke(ArrayFactory.Create(resourceClrType)); + object resource = constructor.Invoke([resourceClrType]); return (IIdentifiable)resource; } @@ -85,14 +85,14 @@ private static IIdentifiable InnerCreateInstance(Type type, IServiceProvider ser /// public NewExpression CreateNewExpression(Type resourceClrType) { - ArgumentGuard.NotNull(resourceClrType, nameof(resourceClrType)); + ArgumentNullException.ThrowIfNull(resourceClrType); if (HasSingleConstructorWithoutParameters(resourceClrType)) { return Expression.New(resourceClrType); } - var constructorArguments = new List(); + List constructorArguments = []; ConstructorInfo longestConstructor = GetLongestConstructor(resourceClrType); @@ -102,7 +102,7 @@ public NewExpression CreateNewExpression(Type resourceClrType) { object constructorArgument = ActivatorUtilities.GetServiceOrCreateInstance(_serviceProvider, constructorParameter.ParameterType); - Expression argumentExpression = constructorArgument.CreateTupleAccessExpressionForConstant(constructorArgument.GetType()); + Expression argumentExpression = SystemExpressionBuilder.CloseOver(constructorArgument); constructorArguments.Add(argumentExpression); } #pragma warning disable AV1210 // Catch a specific exception instead of Exception, SystemException or ApplicationException diff --git a/src/JsonApiDotNetCore/Resources/SortExpressionLambdaConverter.cs b/src/JsonApiDotNetCore/Resources/SortExpressionLambdaConverter.cs index a2371419b6..97e014f344 100644 --- a/src/JsonApiDotNetCore/Resources/SortExpressionLambdaConverter.cs +++ b/src/JsonApiDotNetCore/Resources/SortExpressionLambdaConverter.cs @@ -11,18 +11,18 @@ namespace JsonApiDotNetCore.Resources; internal sealed class SortExpressionLambdaConverter { private readonly IResourceGraph _resourceGraph; - private readonly IList _fields = new List(); + private readonly List _fields = []; public SortExpressionLambdaConverter(IResourceGraph resourceGraph) { - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + ArgumentNullException.ThrowIfNull(resourceGraph); _resourceGraph = resourceGraph; } public SortElementExpression FromLambda(Expression> keySelector, ListSortDirection sortDirection) { - ArgumentGuard.NotNull(keySelector, nameof(keySelector)); + ArgumentNullException.ThrowIfNull(keySelector); _fields.Clear(); @@ -69,7 +69,7 @@ private static Expression SkipConvert(Expression expression) private static (Expression? innerExpression, bool isCount) TryReadCount(Expression expression) { - if (expression is MethodCallExpression methodCallExpression && methodCallExpression.Method.Name == "Count") + if (expression is MethodCallExpression { Method.Name: "Count" } methodCallExpression) { if (methodCallExpression.Arguments.Count <= 1) { @@ -81,7 +81,7 @@ private static (Expression? innerExpression, bool isCount) TryReadCount(Expressi if (expression is MemberExpression memberExpression) { - if (memberExpression.Member.MemberType == MemberTypes.Property && memberExpression.Member.Name is "Count" or "Length") + if (memberExpression.Member is { MemberType: MemberTypes.Property, Name: "Count" or "Length" }) { if (memberExpression.Member.GetCustomAttribute() == null) { @@ -114,12 +114,9 @@ private static (Expression? innerExpression, bool isCount) TryReadCount(Expressi private Expression? ReadAttribute(Expression expression) { - if (expression is MemberExpression memberExpression) + if (expression is MemberExpression { Expression: not null } memberExpression) { - ResourceType resourceType = memberExpression.Member.Name == nameof(Identifiable.Id) && memberExpression.Expression != null - ? _resourceGraph.GetResourceType(memberExpression.Expression.Type) - : _resourceGraph.GetResourceType(memberExpression.Member.DeclaringType!); - + ResourceType resourceType = _resourceGraph.GetResourceType(memberExpression.Expression.Type); AttrAttribute? attribute = resourceType.FindAttributeByPropertyName(memberExpression.Member.Name); if (attribute != null) diff --git a/src/JsonApiDotNetCore/Resources/TargetedFields.cs b/src/JsonApiDotNetCore/Resources/TargetedFields.cs index fe4701c61e..420058106f 100644 --- a/src/JsonApiDotNetCore/Resources/TargetedFields.cs +++ b/src/JsonApiDotNetCore/Resources/TargetedFields.cs @@ -3,23 +3,25 @@ namespace JsonApiDotNetCore.Resources; -/// +/// [PublicAPI] public sealed class TargetedFields : ITargetedFields { - IReadOnlySet ITargetedFields.Attributes => Attributes; - IReadOnlySet ITargetedFields.Relationships => Relationships; + IReadOnlySet ITargetedFields.Attributes => Attributes.AsReadOnly(); + IReadOnlySet ITargetedFields.Relationships => Relationships.AsReadOnly(); - public HashSet Attributes { get; } = new(); - public HashSet Relationships { get; } = new(); + public HashSet Attributes { get; } = []; + public HashSet Relationships { get; } = []; /// public void CopyFrom(ITargetedFields other) { + ArgumentNullException.ThrowIfNull(other); + Clear(); - Attributes.AddRange(other.Attributes); - Relationships.AddRange(other.Relationships); + Attributes.UnionWith(other.Attributes); + Relationships.UnionWith(other.Relationships); } public void Clear() diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/JsonObjectConverter.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/JsonObjectConverter.cs index 32e4351e12..054fc28e55 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonConverters/JsonObjectConverter.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/JsonObjectConverter.cs @@ -7,6 +7,8 @@ public abstract class JsonObjectConverter : JsonConverter { protected static TValue? ReadSubTree(ref Utf8JsonReader reader, JsonSerializerOptions options) { + ArgumentNullException.ThrowIfNull(options); + if (typeof(TValue) != typeof(object) && options.GetConverter(typeof(TValue)) is JsonConverter converter) { return converter.Read(ref reader, typeof(TValue), options); @@ -17,6 +19,8 @@ public abstract class JsonObjectConverter : JsonConverter protected static void WriteSubTree(Utf8JsonWriter writer, TValue value, JsonSerializerOptions options) { + ArgumentNullException.ThrowIfNull(options); + if (typeof(TValue) != typeof(object) && options.GetConverter(typeof(TValue)) is JsonConverter converter) { converter.Write(writer, value, options); diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs index 005e423add..d20bdd5f0d 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs @@ -1,7 +1,10 @@ +using System.Diagnostics.CodeAnalysis; using System.Reflection; +using System.Runtime.ExceptionServices; using System.Text.Json; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; @@ -13,7 +16,7 @@ namespace JsonApiDotNetCore.Serialization.JsonConverters; /// Converts to/from JSON. /// [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class ResourceObjectConverter : JsonObjectConverter +public class ResourceObjectConverter : JsonObjectConverter { private static readonly JsonEncodedText TypeText = JsonEncodedText.Encode("type"); private static readonly JsonEncodedText IdText = JsonEncodedText.Encode("id"); @@ -27,7 +30,7 @@ public sealed class ResourceObjectConverter : JsonObjectConverter>(ref reader, options); + if (resourceType != null) + { + resourceObject.Relationships = ReadRelationships(ref reader, options, resourceType); + } + else + { + reader.Skip(); + } + break; } case "links": @@ -127,27 +138,27 @@ public override ResourceObject Read(ref Utf8JsonReader reader, Type typeToConver throw GetEndOfStreamError(); } - private static string? PeekType(ref Utf8JsonReader reader) + private static string? PeekType(Utf8JsonReader reader) { - // https://docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-converters-how-to?pivots=dotnet-5-0#an-alternative-way-to-do-polymorphic-deserialization - Utf8JsonReader readerClone = reader; + // This method receives a clone of the reader (which is a struct, and there's no ref modifier on the parameter), + // so advancing here doesn't affect the reader position of the caller. - while (readerClone.Read()) + while (reader.Read()) { - if (readerClone.TokenType == JsonTokenType.PropertyName) + if (reader.TokenType == JsonTokenType.PropertyName) { - string? propertyName = readerClone.GetString(); - readerClone.Read(); + string? propertyName = reader.GetString(); + reader.Read(); switch (propertyName) { case "type": { - return readerClone.GetString(); + return reader.GetString(); } default: { - readerClone.Skip(); + reader.Skip(); break; } } @@ -157,7 +168,7 @@ public override ResourceObject Read(ref Utf8JsonReader reader, Type typeToConver return null; } - private static IDictionary ReadAttributes(ref Utf8JsonReader reader, JsonSerializerOptions options, ResourceType resourceType) + private Dictionary ReadAttributes(ref Utf8JsonReader reader, JsonSerializerOptions options, ResourceType resourceType) { var attributes = new Dictionary(); @@ -174,6 +185,18 @@ public override ResourceObject Read(ref Utf8JsonReader reader, Type typeToConver string attributeName = reader.GetString() ?? string.Empty; reader.Read(); + int extensionSeparatorIndex = attributeName.IndexOf(':'); + + if (extensionSeparatorIndex != -1) + { + string extensionNamespace = attributeName[..extensionSeparatorIndex]; + string extensionName = attributeName[(extensionSeparatorIndex + 1)..]; + + ValidateExtensionInAttributes(extensionNamespace, extensionName, resourceType, reader); + reader.Skip(); + continue; + } + AttrAttribute? attribute = resourceType.FindAttributeByPublicName(attributeName); PropertyInfo? property = attribute?.Property; @@ -203,14 +226,61 @@ public override ResourceObject Read(ref Utf8JsonReader reader, Type typeToConver } } - attributes.Add(attributeName, attributeValue); + attributes[attributeName] = attributeValue; } else { - attributes.Add(attributeName, null); + attributes[attributeName] = null; + reader.Skip(); + } + + break; + } + } + } + + throw GetEndOfStreamError(); + } + + // Currently exposed for internal use only, so we don't need a breaking change when adding support for multiple extensions. + // ReSharper disable once UnusedParameter.Global + private protected virtual void ValidateExtensionInAttributes(string extensionNamespace, string extensionName, ResourceType resourceType, + Utf8JsonReader reader) + { + throw new JsonException($"Unsupported usage of JSON:API extension '{extensionNamespace}' in attributes."); + } + + private Dictionary ReadRelationships(ref Utf8JsonReader reader, JsonSerializerOptions options, ResourceType resourceType) + { + var relationships = new Dictionary(); + + while (reader.Read()) + { + switch (reader.TokenType) + { + case JsonTokenType.EndObject: + { + return relationships; + } + case JsonTokenType.PropertyName: + { + string relationshipName = reader.GetString() ?? string.Empty; + reader.Read(); + + int extensionSeparatorIndex = relationshipName.IndexOf(':'); + + if (extensionSeparatorIndex != -1) + { + string extensionNamespace = relationshipName[..extensionSeparatorIndex]; + string extensionName = relationshipName[(extensionSeparatorIndex + 1)..]; + + ValidateExtensionInRelationships(extensionNamespace, extensionName, resourceType, reader); reader.Skip(); + continue; } + var relationshipObject = ReadSubTree(ref reader, options); + relationships[relationshipName] = relationshipObject; break; } } @@ -219,11 +289,22 @@ public override ResourceObject Read(ref Utf8JsonReader reader, Type typeToConver throw GetEndOfStreamError(); } + // Currently exposed for internal use only, so we don't need a breaking change when adding support for multiple extensions. + // ReSharper disable once UnusedParameter.Global + private protected virtual void ValidateExtensionInRelationships(string extensionNamespace, string extensionName, ResourceType resourceType, + Utf8JsonReader reader) + { + throw new JsonException($"Unsupported usage of JSON:API extension '{extensionNamespace}' in relationships."); + } + /// /// Ensures that attribute values are not wrapped in s. /// public override void Write(Utf8JsonWriter writer, ResourceObject value, JsonSerializerOptions options) { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(value); + writer.WriteStartObject(); writer.WriteString(TypeText, value.Type); @@ -241,13 +322,33 @@ public override void Write(Utf8JsonWriter writer, ResourceObject value, JsonSeri if (!value.Attributes.IsNullOrEmpty()) { writer.WritePropertyName(AttributesText); - WriteSubTree(writer, value.Attributes, options); + writer.WriteStartObject(); + + WriteExtensionInAttributes(writer, value); + + foreach ((string attributeName, object? attributeValue) in value.Attributes) + { + writer.WritePropertyName(attributeName); + WriteSubTree(writer, attributeValue, options); + } + + writer.WriteEndObject(); } if (!value.Relationships.IsNullOrEmpty()) { writer.WritePropertyName(RelationshipsText); - WriteSubTree(writer, value.Relationships, options); + writer.WriteStartObject(); + + WriteExtensionInRelationships(writer, value); + + foreach ((string relationshipName, RelationshipObject? relationshipValue) in value.Relationships) + { + writer.WritePropertyName(relationshipName); + WriteSubTree(writer, relationshipValue, options); + } + + writer.WriteEndObject(); } if (value.Links != null && value.Links.HasValue()) @@ -264,4 +365,32 @@ public override void Write(Utf8JsonWriter writer, ResourceObject value, JsonSeri writer.WriteEndObject(); } + + // Currently exposed for internal use only, so we don't need a breaking change when adding support for multiple extensions. + private protected virtual void WriteExtensionInAttributes(Utf8JsonWriter writer, ResourceObject value) + { + } + + // Currently exposed for internal use only, so we don't need a breaking change when adding support for multiple extensions. + private protected virtual void WriteExtensionInRelationships(Utf8JsonWriter writer, ResourceObject value) + { + } + + /// + /// Throws a in such a way that can reconstruct the source pointer. + /// + /// + /// The to throw, which may contain a relative source pointer. + /// + [DoesNotReturn] + [ContractAnnotation("=> halt")] + private protected static void CapturedThrow(JsonApiException exception) + { + ExceptionDispatchInfo.SetCurrentStackTrace(exception); + + throw new NotSupportedException(null, exception) + { + Source = "System.Text.Json.Rethrowable" + }; + } } diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs index 25e497c2c1..81ae41a380 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs @@ -1,4 +1,3 @@ -using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; using JetBrains.Annotations; @@ -12,25 +11,31 @@ namespace JsonApiDotNetCore.Serialization.JsonConverters; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public sealed class SingleOrManyDataConverterFactory : JsonConverterFactory { + /// public override bool CanConvert(Type typeToConvert) { + ArgumentNullException.ThrowIfNull(typeToConvert); + return typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(SingleOrManyData<>); } + /// public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) { + ArgumentNullException.ThrowIfNull(typeToConvert); + Type objectType = typeToConvert.GetGenericArguments()[0]; Type converterType = typeof(SingleOrManyDataConverter<>).MakeGenericType(objectType); - return (JsonConverter)Activator.CreateInstance(converterType, BindingFlags.Instance | BindingFlags.Public, null, null, null)!; + return (JsonConverter)Activator.CreateInstance(converterType)!; } private sealed class SingleOrManyDataConverter : JsonObjectConverter> - where T : class, IResourceIdentity, new() + where T : ResourceIdentifierObject, new() { - public override SingleOrManyData Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions serializerOptions) + public override SingleOrManyData Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - var objects = new List(); + List objects = []; bool isManyData = false; bool hasCompletedToMany = false; @@ -54,7 +59,7 @@ public override SingleOrManyData Read(ref Utf8JsonReader reader, Type typeToC } case JsonTokenType.StartObject: { - var resourceObject = ReadSubTree(ref reader, serializerOptions); + var resourceObject = ReadSubTree(ref reader, options); objects.Add(resourceObject); break; } diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyDocumentConverter.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyDocumentConverter.cs index 623857f5ff..f459b49c9b 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyDocumentConverter.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyDocumentConverter.cs @@ -19,16 +19,22 @@ 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."); } /// - /// Conditionally writes "data": null or omits it, depending on . + /// Conditionally writes or omits it, depending on . /// public override void Write(Utf8JsonWriter writer, Document value, JsonSerializerOptions options) { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(value); + writer.WriteStartObject(); if (value.JsonApi != null) @@ -58,7 +64,28 @@ public override void Write(Utf8JsonWriter writer, Document value, JsonSerializer if (!value.Results.IsNullOrEmpty()) { writer.WritePropertyName(AtomicResultsText); - WriteSubTree(writer, value.Results, options); + writer.WriteStartArray(); + + foreach (AtomicResultObject result in value.Results) + { + writer.WriteStartObject(); + + if (result.Data.IsAssigned) + { + writer.WritePropertyName(DataText); + WriteSubTree(writer, result.Data, options); + } + + if (!result.Meta.IsNullOrEmpty()) + { + writer.WritePropertyName(MetaText); + WriteSubTree(writer, result.Meta, options); + } + + writer.WriteEndObject(); + } + + writer.WriteEndArray(); } if (!value.Errors.IsNullOrEmpty()) diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyRelationshipObjectConverter.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyRelationshipObjectConverter.cs index 047e0737c5..db6b7dc686 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyRelationshipObjectConverter.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyRelationshipObjectConverter.cs @@ -14,16 +14,22 @@ 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."); } /// - /// Conditionally writes "data": null or omits it, depending on . + /// Conditionally writes or omits it, depending on . /// public override void Write(Utf8JsonWriter writer, RelationshipObject value, JsonSerializerOptions options) { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(value); + writer.WriteStartObject(); if (value.Links != null && value.Links.HasValue()) diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicReference.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicReference.cs index 01693d1db6..fcc56298c1 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/AtomicReference.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicReference.cs @@ -7,20 +7,8 @@ namespace JsonApiDotNetCore.Serialization.Objects; /// See "ref" in https://jsonapi.org/ext/atomic/#operation-objects. /// [PublicAPI] -public sealed class AtomicReference : IResourceIdentity +public sealed class AtomicReference : ResourceIdentity { - [JsonPropertyName("type")] - [JsonIgnore(Condition = JsonIgnoreCondition.Never)] - public string? Type { get; set; } - - [JsonPropertyName("id")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Id { get; set; } - - [JsonPropertyName("lid")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Lid { get; set; } - [JsonPropertyName("relationship")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Relationship { get; set; } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/Document.cs b/src/JsonApiDotNetCore/Serialization/Objects/Document.cs index 2f40aeb27b..f21334f5c4 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/Document.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/Document.cs @@ -1,10 +1,12 @@ using System.Text.Json.Serialization; +using JetBrains.Annotations; namespace JsonApiDotNetCore.Serialization.Objects; /// -/// See https://jsonapi.org/format/1.1/#document-top-level and https://jsonapi.org/ext/atomic/#document-structure. +/// See https://jsonapi.org/format#document-top-level and https://jsonapi.org/ext/atomic/#document-structure. /// +[PublicAPI] public sealed class Document { [JsonPropertyName("jsonapi")] diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ErrorLinks.cs b/src/JsonApiDotNetCore/Serialization/Objects/ErrorLinks.cs index 4b8d4de528..6ba2c2a6f6 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ErrorLinks.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ErrorLinks.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCore.Serialization.Objects; /// -/// See "links" in https://jsonapi.org/format/1.1/#error-objects. +/// See "links" in https://jsonapi.org/format/#error-objects. /// [PublicAPI] public sealed class ErrorLinks diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ErrorObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/ErrorObject.cs index 87ad1ebefe..63d95174cd 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ErrorObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ErrorObject.cs @@ -5,10 +5,10 @@ namespace JsonApiDotNetCore.Serialization.Objects; /// -/// See https://jsonapi.org/format/1.1/#error-objects. +/// See https://jsonapi.org/format/#error-objects. /// [PublicAPI] -public sealed class ErrorObject +public sealed class ErrorObject(HttpStatusCode statusCode) { [JsonPropertyName("id")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] @@ -19,7 +19,7 @@ public sealed class ErrorObject public ErrorLinks? Links { get; set; } [JsonIgnore] - public HttpStatusCode StatusCode { get; set; } + public HttpStatusCode StatusCode { get; set; } = statusCode; [JsonPropertyName("status")] [JsonIgnore(Condition = JsonIgnoreCondition.Never)] @@ -49,11 +49,6 @@ public string Status [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public IDictionary? Meta { get; set; } - public ErrorObject(HttpStatusCode statusCode) - { - StatusCode = statusCode; - } - public static HttpStatusCode GetResponseStatusCode(IReadOnlyList errorObjects) { if (errorObjects.IsNullOrEmpty()) diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ErrorSource.cs b/src/JsonApiDotNetCore/Serialization/Objects/ErrorSource.cs index 156f734aa2..b9242895f4 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ErrorSource.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ErrorSource.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCore.Serialization.Objects; /// -/// See "source" in https://jsonapi.org/format/1.1/#error-objects. +/// See "source" in https://jsonapi.org/format/#error-objects. /// [PublicAPI] public sealed class ErrorSource diff --git a/src/JsonApiDotNetCore/Serialization/Objects/IResourceIdentity.cs b/src/JsonApiDotNetCore/Serialization/Objects/IResourceIdentity.cs deleted file mode 100644 index c4b57f535f..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Objects/IResourceIdentity.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace JsonApiDotNetCore.Serialization.Objects; - -public interface IResourceIdentity -{ - public string? Type { get; } - public string? Id { get; } - public string? Lid { get; } -} diff --git a/src/JsonApiDotNetCore/Serialization/Objects/JsonApiObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/JsonApiObject.cs deleted file mode 100644 index 4a48f90099..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Objects/JsonApiObject.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Text.Json.Serialization; -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Serialization.Objects; - -/// -/// See https://jsonapi.org/format/1.1/#document-jsonapi-object. -/// -[PublicAPI] -public sealed class JsonApiObject -{ - [JsonPropertyName("version")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Version { get; set; } - - [JsonPropertyName("ext")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IList? Ext { get; set; } - - [JsonPropertyName("profile")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IList? Profile { get; set; } - - [JsonPropertyName("meta")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary? Meta { get; set; } -} diff --git a/src/JsonApiDotNetCore/Serialization/Objects/JsonapiObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/JsonapiObject.cs new file mode 100644 index 0000000000..66daec22ff --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Objects/JsonapiObject.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Serialization.Objects; + +/// +/// See https://jsonapi.org/format/#document-jsonapi-object. +/// +[PublicAPI] +public sealed class JsonApiObject +{ + [JsonPropertyName("version")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Version { get; set; } + + [JsonPropertyName("ext")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList? Ext { get; set; } + + [JsonPropertyName("profile")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList? Profile { get; set; } + + [JsonPropertyName("meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? Meta { get; set; } +} diff --git a/src/JsonApiDotNetCore/Serialization/Objects/RelationshipLinks.cs b/src/JsonApiDotNetCore/Serialization/Objects/RelationshipLinks.cs index f3f6c2bf02..4c1095e1a7 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/RelationshipLinks.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/RelationshipLinks.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCore.Serialization.Objects; /// -/// See "links" in https://jsonapi.org/format/1.1/#document-resource-object-relationships. +/// See "links" in https://jsonapi.org/format/#document-resource-object-relationships. /// [PublicAPI] public sealed class RelationshipLinks diff --git a/src/JsonApiDotNetCore/Serialization/Objects/RelationshipObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/RelationshipObject.cs index 9411ecf83a..c677e9a0fb 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/RelationshipObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/RelationshipObject.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCore.Serialization.Objects; /// -/// See https://jsonapi.org/format/1.1/#document-resource-object-relationships. +/// See https://jsonapi.org/format/#document-resource-object-relationships. /// [PublicAPI] public sealed class RelationshipObject diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs index a1b8271cf7..e82ebe16bf 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs @@ -4,24 +4,13 @@ namespace JsonApiDotNetCore.Serialization.Objects; /// -/// See https://jsonapi.org/format/1.1/#document-resource-identifier-objects. +/// See https://jsonapi.org/format/#document-resource-identifier-objects. /// [PublicAPI] -public sealed class ResourceIdentifierObject : IResourceIdentity +public class ResourceIdentifierObject : ResourceIdentity { - [JsonPropertyName("type")] - [JsonIgnore(Condition = JsonIgnoreCondition.Never)] - public string? Type { get; set; } - - [JsonPropertyName("id")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Id { get; set; } - - [JsonPropertyName("lid")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Lid { get; set; } - [JsonPropertyName("meta")] + [JsonPropertyOrder(100)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public IDictionary? Meta { get; set; } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentity.cs b/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentity.cs new file mode 100644 index 0000000000..41a3d951e6 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentity.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Serialization.Objects; + +/// +/// Shared identity information for various JSON:API objects. +/// +[PublicAPI] +public abstract class ResourceIdentity +{ + [JsonPropertyName("type")] + [JsonPropertyOrder(-3)] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public string? Type { get; set; } + + [JsonPropertyName("id")] + [JsonPropertyOrder(-2)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Id { get; set; } + + [JsonPropertyName("lid")] + [JsonPropertyOrder(-1)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Lid { get; set; } +} diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ResourceLinks.cs b/src/JsonApiDotNetCore/Serialization/Objects/ResourceLinks.cs index 22c396082d..6f749cca88 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ResourceLinks.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ResourceLinks.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCore.Serialization.Objects; /// -/// See https://jsonapi.org/format/1.1/#document-resource-object-links. +/// See https://jsonapi.org/format/#document-resource-object-links. /// [PublicAPI] public sealed class ResourceLinks diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ResourceObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/ResourceObject.cs index 43b3b9616a..fc1ff3d146 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ResourceObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ResourceObject.cs @@ -4,36 +4,23 @@ namespace JsonApiDotNetCore.Serialization.Objects; /// -/// See https://jsonapi.org/format/1.1/#document-resource-objects. +/// See https://jsonapi.org/format/#document-resource-objects. /// [PublicAPI] -public sealed class ResourceObject : IResourceIdentity +public sealed class ResourceObject : ResourceIdentifierObject { - [JsonPropertyName("type")] - [JsonIgnore(Condition = JsonIgnoreCondition.Never)] - public string? Type { get; set; } - - [JsonPropertyName("id")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Id { get; set; } - - [JsonPropertyName("lid")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string? Lid { get; set; } - [JsonPropertyName("attributes")] + [JsonPropertyOrder(1)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public IDictionary? Attributes { get; set; } [JsonPropertyName("relationships")] + [JsonPropertyOrder(2)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public IDictionary? Relationships { get; set; } [JsonPropertyName("links")] + [JsonPropertyOrder(3)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ResourceLinks? Links { get; set; } - - [JsonPropertyName("meta")] - [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public IDictionary? Meta { get; set; } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/SingleOrManyData.cs b/src/JsonApiDotNetCore/Serialization/Objects/SingleOrManyData.cs index 1d2f99e126..99884d61e6 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/SingleOrManyData.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/SingleOrManyData.cs @@ -9,11 +9,14 @@ namespace JsonApiDotNetCore.Serialization.Objects; /// Represents the value of the "data" element, which is either null, a single object or an array of objects. Add /// to to properly roundtrip. /// +/// +/// The type of elements being wrapped, typically or . +/// [PublicAPI] public readonly struct SingleOrManyData // The "new()" constraint exists for parity with SingleOrManyDataConverterFactory, which creates empty instances // to ensure ManyValue never contains null items. - where T : class, IResourceIdentity, new() + where T : ResourceIdentifierObject, new() { public object? Value => ManyValue != null ? ManyValue : SingleValue; diff --git a/src/JsonApiDotNetCore/Serialization/Objects/TopLevelLinks.cs b/src/JsonApiDotNetCore/Serialization/Objects/TopLevelLinks.cs index 14578253c2..f83510fb23 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/TopLevelLinks.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/TopLevelLinks.cs @@ -4,7 +4,7 @@ namespace JsonApiDotNetCore.Serialization.Objects; /// -/// See "links" in https://jsonapi.org/format/1.1/#document-top-level. +/// See "links" in https://jsonapi.org/format/#document-top-level. /// [PublicAPI] public sealed class TopLevelLinks diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs index 061bd0f920..4339cf6c48 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; @@ -17,10 +17,10 @@ public sealed class AtomicOperationObjectAdapter : IAtomicOperationObjectAdapter public AtomicOperationObjectAdapter(IJsonApiOptions options, IAtomicReferenceAdapter atomicReferenceAdapter, IResourceDataInOperationsRequestAdapter resourceDataInOperationsRequestAdapter, IRelationshipDataAdapter relationshipDataAdapter) { - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(atomicReferenceAdapter, nameof(atomicReferenceAdapter)); - ArgumentGuard.NotNull(resourceDataInOperationsRequestAdapter, nameof(resourceDataInOperationsRequestAdapter)); - ArgumentGuard.NotNull(relationshipDataAdapter, nameof(relationshipDataAdapter)); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(atomicReferenceAdapter); + ArgumentNullException.ThrowIfNull(resourceDataInOperationsRequestAdapter); + ArgumentNullException.ThrowIfNull(relationshipDataAdapter); _options = options; _atomicReferenceAdapter = atomicReferenceAdapter; @@ -31,6 +31,9 @@ public AtomicOperationObjectAdapter(IJsonApiOptions options, IAtomicReferenceAda /// public OperationContainer Convert(AtomicOperationObject atomicOperationObject, RequestAdapterState state) { + ArgumentNullException.ThrowIfNull(atomicOperationObject); + ArgumentNullException.ThrowIfNull(state); + AssertNoHref(atomicOperationObject, state); WriteOperationKind writeOperation = ConvertOperationCode(atomicOperationObject, state); @@ -122,13 +125,12 @@ 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), + EvaluateAllowLid = resourceType => + ResourceIdentityRequirements.DoEvaluateAllowLid(resourceType, state.Request.WriteOperation, _options.ClientIdGeneration) }; } @@ -137,7 +139,8 @@ private static ResourceIdentityRequirements CreateDataRequirements(AtomicReferen return new ResourceIdentityRequirements { ResourceType = refResult.ResourceType, - IdConstraint = refRequirements.IdConstraint, + EvaluateIdConstraint = refRequirements.EvaluateIdConstraint, + EvaluateAllowLid = refRequirements.EvaluateAllowLid, IdValue = refResult.Resource.StringId, LidValue = refResult.Resource.LocalId, RelationshipName = refResult.Relationship?.PublicName diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceAdapter.cs index e1aec5641b..0dc72a08fa 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceAdapter.cs @@ -8,19 +8,15 @@ namespace JsonApiDotNetCore.Serialization.Request.Adapters; /// [PublicAPI] -public sealed class AtomicReferenceAdapter : ResourceIdentityAdapter, IAtomicReferenceAdapter +public sealed class AtomicReferenceAdapter(IResourceGraph resourceGraph, IResourceFactory resourceFactory) + : ResourceIdentityAdapter(resourceGraph, resourceFactory), IAtomicReferenceAdapter { - public AtomicReferenceAdapter(IResourceGraph resourceGraph, IResourceFactory resourceFactory) - : base(resourceGraph, resourceFactory) - { - } - /// public AtomicReferenceResult Convert(AtomicReference atomicReference, ResourceIdentityRequirements requirements, RequestAdapterState state) { - ArgumentGuard.NotNull(atomicReference, nameof(atomicReference)); - ArgumentGuard.NotNull(requirements, nameof(requirements)); - ArgumentGuard.NotNull(state, nameof(state)); + ArgumentNullException.ThrowIfNull(atomicReference); + ArgumentNullException.ThrowIfNull(requirements); + ArgumentNullException.ThrowIfNull(state); using IDisposable _ = state.Position.PushElement("ref"); (IIdentifiable resource, ResourceType resourceType) = ConvertResourceIdentity(atomicReference, requirements, state); @@ -39,6 +35,7 @@ private RelationshipAttribute ConvertRelationship(string relationshipName, Resou AssertIsKnownRelationship(relationship, relationshipName, resourceType, state); AssertToManyInAddOrRemoveRelationship(relationship, state); + AssertRelationshipChangeNotBlocked(relationship, state); return relationship; } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceResult.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceResult.cs index 9e6d982e21..367d7ec2ea 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceResult.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceResult.cs @@ -17,8 +17,8 @@ public sealed class AtomicReferenceResult public AtomicReferenceResult(IIdentifiable resource, ResourceType resourceType, RelationshipAttribute? relationship) { - ArgumentGuard.NotNull(resource, nameof(resource)); - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(resourceType); Resource = resource; ResourceType = resourceType; diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/BaseAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/BaseAdapter.cs index 64e2f6d53b..8969d28fd7 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/BaseAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/BaseAdapter.cs @@ -11,8 +11,10 @@ public abstract class BaseAdapter { [AssertionMethod] protected static void AssertHasData(SingleOrManyData data, RequestAdapterState state) - where T : class, IResourceIdentity, new() + where T : ResourceIdentifierObject, new() { + ArgumentNullException.ThrowIfNull(state); + if (!data.IsAssigned) { throw new ModelConversionException(state.Position, "The 'data' element is required.", null); @@ -21,8 +23,10 @@ protected static void AssertHasData(SingleOrManyData data, RequestAdapterS [AssertionMethod] protected static void AssertDataHasSingleValue(SingleOrManyData data, bool allowNull, RequestAdapterState state) - where T : class, IResourceIdentity, new() + where T : ResourceIdentifierObject, new() { + ArgumentNullException.ThrowIfNull(state); + if (data.SingleValue == null) { if (!allowNull) @@ -44,8 +48,10 @@ protected static void AssertDataHasSingleValue(SingleOrManyData data, bool [AssertionMethod] protected static void AssertDataHasManyValue(SingleOrManyData data, RequestAdapterState state) - where T : class, IResourceIdentity, new() + where T : ResourceIdentifierObject, new() { + ArgumentNullException.ThrowIfNull(state); + if (data.ManyValue == null) { throw new ModelConversionException(state.Position, @@ -56,6 +62,8 @@ protected static void AssertDataHasManyValue(SingleOrManyData data, Reques protected static void AssertObjectIsNotNull([SysNotNull] T? value, RequestAdapterState state) where T : class { + ArgumentNullException.ThrowIfNull(state); + if (value is null) { throw new ModelConversionException(state.Position, "Expected an object, instead of 'null'.", null); diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentAdapter.cs index 369f9076d2..f5b514b202 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; @@ -16,10 +16,10 @@ public DocumentAdapter(IJsonApiRequest request, ITargetedFields targetedFields, IDocumentInResourceOrRelationshipRequestAdapter documentInResourceOrRelationshipRequestAdapter, IDocumentInOperationsRequestAdapter documentInOperationsRequestAdapter) { - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); - ArgumentGuard.NotNull(documentInResourceOrRelationshipRequestAdapter, nameof(documentInResourceOrRelationshipRequestAdapter)); - ArgumentGuard.NotNull(documentInOperationsRequestAdapter, nameof(documentInOperationsRequestAdapter)); + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(targetedFields); + ArgumentNullException.ThrowIfNull(documentInResourceOrRelationshipRequestAdapter); + ArgumentNullException.ThrowIfNull(documentInOperationsRequestAdapter); _request = request; _targetedFields = targetedFields; @@ -30,7 +30,7 @@ public DocumentAdapter(IJsonApiRequest request, ITargetedFields targetedFields, /// public object? Convert(Document document) { - ArgumentGuard.NotNull(document, nameof(document)); + ArgumentNullException.ThrowIfNull(document); using var adapterState = new RequestAdapterState(_request, _targetedFields); diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInOperationsRequestAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInOperationsRequestAdapter.cs index 8a50db9fec..bb75927c92 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInOperationsRequestAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInOperationsRequestAdapter.cs @@ -13,17 +13,19 @@ public sealed class DocumentInOperationsRequestAdapter : BaseAdapter, IDocumentI public DocumentInOperationsRequestAdapter(IJsonApiOptions options, IAtomicOperationObjectAdapter atomicOperationObjectAdapter) { - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(atomicOperationObjectAdapter, nameof(atomicOperationObjectAdapter)); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(atomicOperationObjectAdapter); _options = options; _atomicOperationObjectAdapter = atomicOperationObjectAdapter; } /// - public IReadOnlyList Convert(Document document, RequestAdapterState state) + public IList Convert(Document document, RequestAdapterState state) { - ArgumentGuard.NotNull(state, nameof(state)); + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(state); + AssertHasOperations(document.Operations, state); using IDisposable _ = state.Position.PushElement("atomic:operations"); @@ -40,19 +42,18 @@ private static void AssertHasOperations([NotNull] IEnumerable atomicOperationObjects, RequestAdapterState state) + private void AssertMaxOperationsNotExceeded(IList atomicOperationObjects, RequestAdapterState state) { if (atomicOperationObjects.Count > _options.MaximumOperationsPerRequest) { throw new ModelConversionException(state.Position, "Too many operations in request.", - $"The number of operations in this request ({atomicOperationObjects.Count}) is higher " + - $"than the maximum of {_options.MaximumOperationsPerRequest}."); + $"The number of operations in this request ({atomicOperationObjects.Count}) is higher than the maximum of {_options.MaximumOperationsPerRequest}."); } } - private IReadOnlyList ConvertOperations(IEnumerable atomicOperationObjects, RequestAdapterState state) + private List ConvertOperations(IEnumerable atomicOperationObjects, RequestAdapterState state) { - var operations = new List(); + List operations = []; int operationIndex = 0; foreach (AtomicOperationObject? atomicOperationObject in atomicOperationObjects) diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs index aaf5b813c8..48b0c2c5f6 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; @@ -15,9 +15,9 @@ public sealed class DocumentInResourceOrRelationshipRequestAdapter : IDocumentIn public DocumentInResourceOrRelationshipRequestAdapter(IJsonApiOptions options, IResourceDataAdapter resourceDataAdapter, IRelationshipDataAdapter relationshipDataAdapter) { - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(resourceDataAdapter, nameof(resourceDataAdapter)); - ArgumentGuard.NotNull(relationshipDataAdapter, nameof(relationshipDataAdapter)); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(resourceDataAdapter); + ArgumentNullException.ThrowIfNull(relationshipDataAdapter); _options = options; _resourceDataAdapter = resourceDataAdapter; @@ -27,6 +27,9 @@ public DocumentInResourceOrRelationshipRequestAdapter(IJsonApiOptions options, I /// public object? Convert(Document document, RequestAdapterState state) { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(state); + state.WritableTargetedFields = new TargetedFields(); switch (state.Request.WriteOperation) @@ -48,6 +51,7 @@ public DocumentInResourceOrRelationshipRequestAdapter(IJsonApiOptions options, I } ResourceIdentityAdapter.AssertToManyInAddOrRemoveRelationship(state.Request.Relationship, state); + ResourceIdentityAdapter.AssertRelationshipChangeNotBlocked(state.Request.Relationship, state); state.WritableTargetedFields.Relationships.Add(state.Request.Relationship); return _relationshipDataAdapter.Convert(document.Data, state.Request.Relationship, false, state); @@ -59,14 +63,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/IDocumentAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentAdapter.cs index 794278fc73..5480d41c01 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentAdapter.cs @@ -13,22 +13,22 @@ public interface IDocumentAdapter /// /// /// - /// ]]> (operations) + /// ]]> (operations) /// /// /// /// - /// ]]> (to-many relationship, unknown relationship) + /// ]]> (to-many relationship, unknown relationship) /// /// /// /// - /// (resource, to-one relationship) + /// (resource, to-one relationship) /// /// /// /// - /// (to-one relationship) + /// (to-one relationship) /// /// /// diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInOperationsRequestAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInOperationsRequestAdapter.cs index b4e929cba3..7f6a6935cf 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInOperationsRequestAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInOperationsRequestAdapter.cs @@ -11,5 +11,7 @@ public interface IDocumentInOperationsRequestAdapter /// /// Validates and converts the specified . /// - IReadOnlyList Convert(Document document, RequestAdapterState state); +#pragma warning disable AV1130 // Return type in method signature should be an interface to an unchangeable collection + IList Convert(Document document, RequestAdapterState state); +#pragma warning restore AV1130 // Return type in method signature should be an interface to an unchangeable collection } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs index 89cf3caa18..a488e727af 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs @@ -1,4 +1,5 @@ using System.Collections; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; @@ -8,13 +9,11 @@ namespace JsonApiDotNetCore.Serialization.Request.Adapters; /// public sealed class RelationshipDataAdapter : BaseAdapter, IRelationshipDataAdapter { - private static readonly CollectionConverter CollectionConverter = new(); - private readonly IResourceIdentifierObjectAdapter _resourceIdentifierObjectAdapter; public RelationshipDataAdapter(IResourceIdentifierObjectAdapter resourceIdentifierObjectAdapter) { - ArgumentGuard.NotNull(resourceIdentifierObjectAdapter, nameof(resourceIdentifierObjectAdapter)); + ArgumentNullException.ThrowIfNull(resourceIdentifierObjectAdapter); _resourceIdentifierObjectAdapter = resourceIdentifierObjectAdapter; } @@ -61,8 +60,8 @@ private static SingleOrManyData ToIdentifierData(Singl public object? Convert(SingleOrManyData data, RelationshipAttribute relationship, bool useToManyElementType, RequestAdapterState state) { - ArgumentGuard.NotNull(relationship, nameof(relationship)); - ArgumentGuard.NotNull(state, nameof(state)); + ArgumentNullException.ThrowIfNull(relationship); + ArgumentNullException.ThrowIfNull(state); AssertHasData(data, state); using IDisposable _ = state.Position.PushElement("data"); @@ -70,7 +69,8 @@ private static SingleOrManyData ToIdentifierData(Singl var requirements = new ResourceIdentityRequirements { ResourceType = relationship.RightType, - IdConstraint = JsonElementConstraint.Required, + EvaluateIdConstraint = _ => JsonElementConstraint.Required, + EvaluateAllowLid = _ => state.Request.Kind == EndpointKind.AtomicOperations, RelationshipName = relationship.PublicName }; @@ -93,7 +93,7 @@ private IEnumerable ConvertToManyRelationshipData(SingleOrManyData(); + List rightResources = []; foreach (ResourceIdentifierObject resourceIdentifierObject in data.ManyValue!) { @@ -107,11 +107,11 @@ private IEnumerable ConvertToManyRelationshipData(SingleOrManyData(IdentifiableComparer.Instance); - resourceSet.AddRange(rightResources); + resourceSet.UnionWith(rightResources); return resourceSet; } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterPosition.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterPosition.cs index 3ae7caa6af..da3ef481b5 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterPosition.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterPosition.cs @@ -1,6 +1,8 @@ using System.Text; using JetBrains.Annotations; +#pragma warning disable CA1001 // Types that own disposable fields should be disposable + namespace JsonApiDotNetCore.Serialization.Request.Adapters; /// @@ -19,7 +21,7 @@ public RequestAdapterPosition() public IDisposable PushElement(string name) { - ArgumentGuard.NotNullNorEmpty(name, nameof(name)); + ArgumentException.ThrowIfNullOrWhiteSpace(name); _stack.Push($"/{name}"); return _disposable; @@ -33,7 +35,7 @@ public IDisposable PushArrayIndex(int index) public string? ToSourcePointer() { - if (!_stack.Any()) + if (_stack.Count == 0) { return null; } @@ -41,7 +43,7 @@ public IDisposable PushArrayIndex(int index) var builder = new StringBuilder(); var clone = new Stack(_stack); - while (clone.Any()) + while (clone.Count > 0) { string element = clone.Pop(); builder.Append(element); @@ -55,14 +57,9 @@ public override string ToString() return ToSourcePointer() ?? string.Empty; } - private sealed class PopStackOnDispose : IDisposable + private sealed class PopStackOnDispose(RequestAdapterPosition owner) : IDisposable { - private readonly RequestAdapterPosition _owner; - - public PopStackOnDispose(RequestAdapterPosition owner) - { - _owner = owner; - } + private readonly RequestAdapterPosition _owner = owner; public void Dispose() { diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterState.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterState.cs index 88cf686f51..089bd48bdf 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterState.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterState.cs @@ -11,7 +11,7 @@ namespace JsonApiDotNetCore.Serialization.Request.Adapters; [PublicAPI] public sealed class RequestAdapterState : IDisposable { - private readonly IDisposable? _backupRequestState; + private readonly RevertRequestStateOnDispose? _backupRequestState; public IJsonApiRequest InjectableRequest { get; } public ITargetedFields InjectableTargetedFields { get; } @@ -24,8 +24,8 @@ public sealed class RequestAdapterState : IDisposable public RequestAdapterState(IJsonApiRequest request, ITargetedFields targetedFields) { - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(targetedFields); InjectableRequest = request; InjectableTargetedFields = targetedFields; diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataAdapter.cs index dc84fbad3d..91f745327d 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataAdapter.cs @@ -12,8 +12,8 @@ public class ResourceDataAdapter : BaseAdapter, IResourceDataAdapter public ResourceDataAdapter(IResourceDefinitionAccessor resourceDefinitionAccessor, IResourceObjectAdapter resourceObjectAdapter) { - ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); - ArgumentGuard.NotNull(resourceObjectAdapter, nameof(resourceObjectAdapter)); + ArgumentNullException.ThrowIfNull(resourceDefinitionAccessor); + ArgumentNullException.ThrowIfNull(resourceObjectAdapter); _resourceDefinitionAccessor = resourceDefinitionAccessor; _resourceObjectAdapter = resourceObjectAdapter; @@ -22,8 +22,8 @@ public ResourceDataAdapter(IResourceDefinitionAccessor resourceDefinitionAccesso /// public IIdentifiable Convert(SingleOrManyData data, ResourceIdentityRequirements requirements, RequestAdapterState state) { - ArgumentGuard.NotNull(requirements, nameof(requirements)); - ArgumentGuard.NotNull(state, nameof(state)); + ArgumentNullException.ThrowIfNull(requirements); + ArgumentNullException.ThrowIfNull(state); AssertHasData(data, state); @@ -42,6 +42,9 @@ public IIdentifiable Convert(SingleOrManyData data, ResourceIden protected virtual (IIdentifiable resource, ResourceType resourceType) ConvertResourceObject(SingleOrManyData data, ResourceIdentityRequirements requirements, RequestAdapterState state) { + ArgumentNullException.ThrowIfNull(requirements); + ArgumentNullException.ThrowIfNull(state); + return _resourceObjectAdapter.Convert(data.SingleValue!, requirements, state); } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataInOperationsRequestAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataInOperationsRequestAdapter.cs index afccb303b5..30deb3c9ba 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataInOperationsRequestAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataInOperationsRequestAdapter.cs @@ -5,16 +5,15 @@ namespace JsonApiDotNetCore.Serialization.Request.Adapters; /// -public sealed class ResourceDataInOperationsRequestAdapter : ResourceDataAdapter, IResourceDataInOperationsRequestAdapter +public sealed class ResourceDataInOperationsRequestAdapter(IResourceDefinitionAccessor resourceDefinitionAccessor, IResourceObjectAdapter resourceObjectAdapter) + : ResourceDataAdapter(resourceDefinitionAccessor, resourceObjectAdapter), IResourceDataInOperationsRequestAdapter { - public ResourceDataInOperationsRequestAdapter(IResourceDefinitionAccessor resourceDefinitionAccessor, IResourceObjectAdapter resourceObjectAdapter) - : base(resourceDefinitionAccessor, resourceObjectAdapter) - { - } - protected override (IIdentifiable resource, ResourceType resourceType) ConvertResourceObject(SingleOrManyData data, ResourceIdentityRequirements requirements, RequestAdapterState state) { + ArgumentNullException.ThrowIfNull(requirements); + ArgumentNullException.ThrowIfNull(state); + // This override ensures that we enrich IJsonApiRequest before calling into IResourceDefinition, so it is ready for consumption there. (IIdentifiable resource, ResourceType resourceType) = base.ConvertResourceObject(data, requirements, state); diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentifierObjectAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentifierObjectAdapter.cs index d0e1b54856..05e2bcdafb 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentifierObjectAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentifierObjectAdapter.cs @@ -5,19 +5,15 @@ namespace JsonApiDotNetCore.Serialization.Request.Adapters; /// -public sealed class ResourceIdentifierObjectAdapter : ResourceIdentityAdapter, IResourceIdentifierObjectAdapter +public sealed class ResourceIdentifierObjectAdapter(IResourceGraph resourceGraph, IResourceFactory resourceFactory) + : ResourceIdentityAdapter(resourceGraph, resourceFactory), IResourceIdentifierObjectAdapter { - public ResourceIdentifierObjectAdapter(IResourceGraph resourceGraph, IResourceFactory resourceFactory) - : base(resourceGraph, resourceFactory) - { - } - /// public IIdentifiable Convert(ResourceIdentifierObject resourceIdentifierObject, ResourceIdentityRequirements requirements, RequestAdapterState state) { - ArgumentGuard.NotNull(resourceIdentifierObject, nameof(resourceIdentifierObject)); - ArgumentGuard.NotNull(requirements, nameof(requirements)); - ArgumentGuard.NotNull(state, nameof(state)); + ArgumentNullException.ThrowIfNull(resourceIdentifierObject); + ArgumentNullException.ThrowIfNull(requirements); + ArgumentNullException.ThrowIfNull(state); (IIdentifiable resource, _) = ConvertResourceIdentity(resourceIdentifierObject, requirements, state); return resource; diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs index d163eb56d1..d0def4e9cd 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs @@ -18,27 +18,27 @@ public abstract class ResourceIdentityAdapter : BaseAdapter protected ResourceIdentityAdapter(IResourceGraph resourceGraph, IResourceFactory resourceFactory) { - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - ArgumentGuard.NotNull(resourceFactory, nameof(resourceFactory)); + ArgumentNullException.ThrowIfNull(resourceGraph); + ArgumentNullException.ThrowIfNull(resourceFactory); _resourceGraph = resourceGraph; _resourceFactory = resourceFactory; } - protected (IIdentifiable resource, ResourceType resourceType) ConvertResourceIdentity(IResourceIdentity identity, ResourceIdentityRequirements requirements, + protected (IIdentifiable resource, ResourceType resourceType) ConvertResourceIdentity(ResourceIdentity identity, ResourceIdentityRequirements requirements, RequestAdapterState state) { - ArgumentGuard.NotNull(identity, nameof(identity)); - ArgumentGuard.NotNull(requirements, nameof(requirements)); - ArgumentGuard.NotNull(state, nameof(state)); + ArgumentNullException.ThrowIfNull(identity); + ArgumentNullException.ThrowIfNull(requirements); + ArgumentNullException.ThrowIfNull(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); } - private ResourceType ResolveType(IResourceIdentity identity, ResourceIdentityRequirements requirements, RequestAdapterState state) + private ResourceType ResolveType(ResourceIdentity identity, ResourceIdentityRequirements requirements, RequestAdapterState state) { AssertHasType(identity.Type, state); @@ -93,51 +93,60 @@ private static void AssertIsCompatibleResourceType(ResourceType actual, Resource } } - private IIdentifiable CreateResource(IResourceIdentity identity, ResourceIdentityRequirements requirements, Type resourceClrType, RequestAdapterState state) + private IIdentifiable CreateResource(ResourceIdentity identity, ResourceIdentityRequirements requirements, ResourceType resourceType, + RequestAdapterState state) { - if (state.Request.Kind != EndpointKind.AtomicOperations) + AssertNoIdWithLid(identity, state); + + bool allowLid = requirements.EvaluateAllowLid?.Invoke(resourceType) ?? false; + + if (!allowLid) { AssertHasNoLid(identity, state); } - AssertNoIdWithLid(identity, state); + JsonElementConstraint? idConstraint = requirements.EvaluateIdConstraint?.Invoke(resourceType); - if (requirements.IdConstraint == JsonElementConstraint.Required) + if (idConstraint == JsonElementConstraint.Required) { - AssertHasIdOrLid(identity, requirements, state); + AssertHasIdOrLid(identity, requirements, allowLid, state); } - else if (requirements.IdConstraint == JsonElementConstraint.Forbidden) + else if (idConstraint == JsonElementConstraint.Forbidden) { AssertHasNoId(identity, state); } + AssertNoBrokenId(identity, resourceType.IdentityClrType, state); 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; } - private static void AssertHasNoLid(IResourceIdentity identity, RequestAdapterState state) + private static void AssertHasNoLid(ResourceIdentity identity, RequestAdapterState state) { if (identity.Lid != null) { using IDisposable _ = state.Position.PushElement("lid"); - throw new ModelConversionException(state.Position, "The 'lid' element is not supported at this endpoint.", null); + + throw state.Request.Kind == EndpointKind.AtomicOperations + ? new ModelConversionException(state.Position, "The 'lid' element cannot be used because a client-generated ID is required.", null) + : new ModelConversionException(state.Position, "The 'lid' element is not supported at this endpoint.", null); } } - private static void AssertNoIdWithLid(IResourceIdentity identity, RequestAdapterState state) + private static void AssertNoIdWithLid(ResourceIdentity identity, RequestAdapterState state) { - if (identity.Id != null && identity.Lid != null) + if (identity is { Id: not null, Lid: not null }) { throw new ModelConversionException(state.Position, "The 'id' and 'lid' element are mutually exclusive.", null); } } - private static void AssertHasIdOrLid(IResourceIdentity identity, ResourceIdentityRequirements requirements, RequestAdapterState state) + private static void AssertHasIdOrLid(ResourceIdentity identity, ResourceIdentityRequirements requirements, bool allowLid, RequestAdapterState state) { string? message = null; @@ -151,7 +160,7 @@ private static void AssertHasIdOrLid(IResourceIdentity identity, ResourceIdentit } else if (identity.Id == null && identity.Lid == null) { - message = state.Request.Kind == EndpointKind.AtomicOperations ? "The 'id' or 'lid' element is required." : "The 'id' element is required."; + message = allowLid ? "The 'id' or 'lid' element is required." : "The 'id' element is required."; } if (message != null) @@ -160,7 +169,7 @@ private static void AssertHasIdOrLid(IResourceIdentity identity, ResourceIdentit } } - private static void AssertHasNoId(IResourceIdentity identity, RequestAdapterState state) + private static void AssertHasNoId(ResourceIdentity identity, RequestAdapterState state) { if (identity.Id != null) { @@ -169,7 +178,26 @@ private static void AssertHasNoId(IResourceIdentity identity, RequestAdapterStat } } - private static void AssertSameIdValue(IResourceIdentity identity, string? expected, RequestAdapterState state) + private static void AssertNoBrokenId(ResourceIdentity identity, Type resourceIdClrType, RequestAdapterState state) + { + if (identity.Id != null) + { + if (resourceIdClrType == typeof(string)) + { + // Empty and whitespace strings are valid when TId is string. + return; + } + + string? defaultIdValue = RuntimeTypeConverter.GetDefaultValue(resourceIdClrType)?.ToString(); + + if (string.IsNullOrWhiteSpace(identity.Id) || identity.Id == defaultIdValue) + { + throw new ModelConversionException(state.Position, "The 'id' element is invalid.", null); + } + } + } + + private static void AssertSameIdValue(ResourceIdentity identity, string? expected, RequestAdapterState state) { if (expected != null && identity.Id != expected) { @@ -180,7 +208,7 @@ private static void AssertSameIdValue(IResourceIdentity identity, string? expect } } - private static void AssertSameLidValue(IResourceIdentity identity, string? expected, RequestAdapterState state) + private static void AssertSameLidValue(ResourceIdentity identity, string? expected, RequestAdapterState state) { if (expected != null && identity.Lid != expected) { @@ -191,7 +219,7 @@ private static void AssertSameLidValue(IResourceIdentity identity, string? expec } } - private void AssignStringId(IResourceIdentity identity, IIdentifiable resource, RequestAdapterState state) + private void AssignStringId(ResourceIdentity identity, IIdentifiable resource, RequestAdapterState state) { if (identity.Id != null) { @@ -210,6 +238,10 @@ private void AssignStringId(IResourceIdentity identity, IIdentifiable resource, protected static void AssertIsKnownRelationship([NotNull] RelationshipAttribute? relationship, string relationshipName, ResourceType resourceType, RequestAdapterState state) { + ArgumentNullException.ThrowIfNull(relationshipName); + ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(state); + if (relationship == null) { throw new ModelConversionException(state.Position, "Unknown relationship found.", @@ -219,6 +251,9 @@ protected static void AssertIsKnownRelationship([NotNull] RelationshipAttribute? protected internal static void AssertToManyInAddOrRemoveRelationship(RelationshipAttribute relationship, RequestAdapterState state) { + ArgumentNullException.ThrowIfNull(relationship); + ArgumentNullException.ThrowIfNull(state); + bool requireToManyRelationship = state.Request.WriteOperation is WriteOperationKind.AddToRelationship or WriteOperationKind.RemoveFromRelationship; if (requireToManyRelationship && relationship is not HasManyAttribute) @@ -231,4 +266,53 @@ protected internal static void AssertToManyInAddOrRemoveRelationship(Relationshi HttpStatusCode.Forbidden); } } + + internal static void AssertRelationshipChangeNotBlocked(RelationshipAttribute relationship, RequestAdapterState state) + { + switch (state.Request.WriteOperation) + { + case WriteOperationKind.AddToRelationship: + { + AssertAddToRelationshipNotBlocked((HasManyAttribute)relationship, state); + break; + } + case WriteOperationKind.RemoveFromRelationship: + { + AssertRemoveFromRelationshipNotBlocked((HasManyAttribute)relationship, state); + break; + } + default: + { + AssertSetRelationshipNotBlocked(relationship, state); + break; + } + } + } + + private static void AssertSetRelationshipNotBlocked(RelationshipAttribute relationship, RequestAdapterState state) + { + if (relationship.IsSetBlocked()) + { + throw new ModelConversionException(state.Position, "Relationship cannot be assigned.", + $"The relationship '{relationship.PublicName}' on resource type '{relationship.LeftType.PublicName}' cannot be assigned to."); + } + } + + private static void AssertAddToRelationshipNotBlocked(HasManyAttribute relationship, RequestAdapterState state) + { + if (!relationship.Capabilities.HasFlag(HasManyCapabilities.AllowAdd)) + { + throw new ModelConversionException(state.Position, "Relationship cannot be added to.", + $"The relationship '{relationship.PublicName}' on resource type '{relationship.LeftType.PublicName}' cannot be added to."); + } + } + + private static void AssertRemoveFromRelationshipNotBlocked(HasManyAttribute relationship, RequestAdapterState state) + { + if (!relationship.Capabilities.HasFlag(HasManyCapabilities.AllowRemove)) + { + throw new ModelConversionException(state.Position, "Relationship cannot be removed from.", + $"The relationship '{relationship.PublicName}' on resource type '{relationship.LeftType.PublicName}' cannot be removed from."); + } + } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityRequirements.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityRequirements.cs index 11db5e8ee3..0168d2d5ea 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityRequirements.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityRequirements.cs @@ -1,11 +1,12 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Serialization.Objects; namespace JsonApiDotNetCore.Serialization.Request.Adapters; /// -/// Defines requirements to validate an instance against. +/// Defines requirements to validate a instance against. /// [PublicAPI] public sealed class ResourceIdentityRequirements @@ -16,9 +17,14 @@ 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, provides a callback to indicate whether the "lid" element can be used instead of the "id" element. Defaults to false. + /// + public Func? EvaluateAllowLid { get; init; } /// /// When not null, indicates what the value of the "id" element must be. @@ -34,4 +40,30 @@ 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; + } + + internal static bool DoEvaluateAllowLid(ResourceType resourceType, WriteOperationKind? writeOperation, ClientIdGenerationMode globalClientIdGeneration) + { + if (writeOperation == null) + { + return false; + } + + ClientIdGenerationMode clientIdGeneration = resourceType.ClientIdGeneration ?? globalClientIdGeneration; + return !(writeOperation == WriteOperationKind.CreateResource && clientIdGeneration == ClientIdGenerationMode.Required); + } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs index 2199782a3a..5f9b4dd05c 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs @@ -17,8 +17,8 @@ public ResourceObjectAdapter(IResourceGraph resourceGraph, IResourceFactory reso IRelationshipDataAdapter relationshipDataAdapter) : base(resourceGraph, resourceFactory) { - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(relationshipDataAdapter, nameof(relationshipDataAdapter)); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(relationshipDataAdapter); _options = options; _relationshipDataAdapter = relationshipDataAdapter; @@ -28,9 +28,9 @@ public ResourceObjectAdapter(IResourceGraph resourceGraph, IResourceFactory reso public (IIdentifiable resource, ResourceType resourceType) Convert(ResourceObject resourceObject, ResourceIdentityRequirements requirements, RequestAdapterState state) { - ArgumentGuard.NotNull(resourceObject, nameof(resourceObject)); - ArgumentGuard.NotNull(requirements, nameof(requirements)); - ArgumentGuard.NotNull(state, nameof(state)); + ArgumentNullException.ThrowIfNull(resourceObject); + ArgumentNullException.ThrowIfNull(requirements); + ArgumentNullException.ThrowIfNull(state); (IIdentifiable resource, ResourceType resourceType) = ConvertResourceIdentity(resourceObject, requirements, state); @@ -63,8 +63,8 @@ private void ConvertAttribute(IIdentifiable resource, string attributeName, obje AssertIsKnownAttribute(attr, attributeName, resourceType, state); AssertNoInvalidAttribute(attributeValue, state); - AssertNoBlockedCreate(attr, resourceType, state); - AssertNoBlockedChange(attr, resourceType, state); + AssertSetAttributeInCreateResourceNotBlocked(attr, resourceType, state); + AssertSetAttributeInUpdateResourceNotBlocked(attr, resourceType, state); AssertNotReadOnly(attr, resourceType, state); attr.SetValue(resource, attributeValue); @@ -96,7 +96,7 @@ private static void AssertNoInvalidAttribute(object? attributeValue, RequestAdap } } - private static void AssertNoBlockedCreate(AttrAttribute attr, ResourceType resourceType, RequestAdapterState state) + private static void AssertSetAttributeInCreateResourceNotBlocked(AttrAttribute attr, ResourceType resourceType, RequestAdapterState state) { if (state.Request.WriteOperation == WriteOperationKind.CreateResource && !attr.Capabilities.HasFlag(AttrCapabilities.AllowCreate)) { @@ -105,7 +105,7 @@ private static void AssertNoBlockedCreate(AttrAttribute attr, ResourceType resou } } - private static void AssertNoBlockedChange(AttrAttribute attr, ResourceType resourceType, RequestAdapterState state) + private static void AssertSetAttributeInUpdateResourceNotBlocked(AttrAttribute attr, ResourceType resourceType, RequestAdapterState state) { if (state.Request.WriteOperation == WriteOperationKind.UpdateResource && !attr.Capabilities.HasFlag(AttrCapabilities.AllowChange)) { @@ -148,6 +148,7 @@ private void ConvertRelationship(string relationshipName, RelationshipObject? re } AssertIsKnownRelationship(relationship, relationshipName, resourceType, state); + AssertRelationshipChangeNotBlocked(relationship, state); object? rightValue = _relationshipDataAdapter.Convert(relationshipObject.Data, relationship, true, state); diff --git a/src/JsonApiDotNetCore/Serialization/Request/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/Request/JsonApiReader.cs index 0942683487..9c0139d949 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/JsonApiReader.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/JsonApiReader.cs @@ -5,7 +5,6 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Diagnostics; using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCore.Serialization.Request.Adapters; using Microsoft.AspNetCore.Http; @@ -16,32 +15,37 @@ namespace JsonApiDotNetCore.Serialization.Request; -/// -public sealed class JsonApiReader : IJsonApiReader +/// +public sealed partial class JsonApiReader : IJsonApiReader { private readonly IJsonApiOptions _options; private readonly IDocumentAdapter _documentAdapter; - private readonly TraceLogWriter _traceWriter; + private readonly ILogger _logger; - public JsonApiReader(IJsonApiOptions options, IDocumentAdapter documentAdapter, ILoggerFactory loggerFactory) + public JsonApiReader(IJsonApiOptions options, IDocumentAdapter documentAdapter, ILogger logger) { - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(documentAdapter, nameof(documentAdapter)); - ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(documentAdapter); + ArgumentNullException.ThrowIfNull(logger); _options = options; _documentAdapter = documentAdapter; - _traceWriter = new TraceLogWriter(loggerFactory); + _logger = logger; } /// public async Task ReadAsync(HttpRequest httpRequest) { - ArgumentGuard.NotNull(httpRequest, nameof(httpRequest)); + ArgumentNullException.ThrowIfNull(httpRequest); string requestBody = await ReceiveRequestBodyAsync(httpRequest); - _traceWriter.LogMessage(() => $"Received {httpRequest.Method} request at '{httpRequest.GetEncodedUrl()}' with body: <<{requestBody}>>"); + if (_logger.IsEnabled(LogLevel.Trace)) + { + string requestMethod = httpRequest.Method.Replace(Environment.NewLine, ""); + string requestUrl = httpRequest.GetEncodedUrl(); + LogRequest(requestMethod, requestUrl, requestBody); + } return GetModel(requestBody); } @@ -93,6 +97,10 @@ private Document DeserializeDocument(string requestBody) // https://github.com/dotnet/runtime/issues/50205#issuecomment-808401245 throw new InvalidRequestBodyException(_options.IncludeRequestBodyInErrors ? requestBody : null, null, exception.Message, null, null, exception); } + catch (NotSupportedException exception) when (exception.HasJsonApiException()) + { + throw exception.EnrichSourcePointer(); + } } private void AssertHasDocument([SysNotNull] Document? document, string requestBody) @@ -116,4 +124,8 @@ private void AssertHasDocument([SysNotNull] Document? document, string requestBo exception.SourcePointer, exception.StatusCode, exception); } } + + [LoggerMessage(Level = LogLevel.Trace, SkipEnabledCheck = true, + Message = "Received {RequestMethod} request at '{RequestUrl}' with body: <<{RequestBody}>>")] + private partial void LogRequest(string requestMethod, string requestUrl, string requestBody); } diff --git a/src/JsonApiDotNetCore/Serialization/Request/JsonInvalidAttributeInfo.cs b/src/JsonApiDotNetCore/Serialization/Request/JsonInvalidAttributeInfo.cs index 02ef36aa53..4004e83de9 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/JsonInvalidAttributeInfo.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/JsonInvalidAttributeInfo.cs @@ -16,8 +16,8 @@ internal sealed class JsonInvalidAttributeInfo public JsonInvalidAttributeInfo(string attributeName, Type attributeType, string? jsonValue, JsonValueKind jsonType) { - ArgumentGuard.NotNullNorEmpty(attributeName, nameof(attributeName)); - ArgumentGuard.NotNull(attributeType, nameof(attributeType)); + ArgumentNullException.ThrowIfNull(attributeName); + ArgumentNullException.ThrowIfNull(attributeType); AttributeName = attributeName; AttributeType = attributeType; diff --git a/src/JsonApiDotNetCore/Serialization/Request/ModelConversionException.cs b/src/JsonApiDotNetCore/Serialization/Request/ModelConversionException.cs index b43af538e5..02db72573d 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/ModelConversionException.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/ModelConversionException.cs @@ -18,7 +18,7 @@ public sealed class ModelConversionException : Exception public ModelConversionException(RequestAdapterPosition position, string? genericMessage, string? specificMessage, HttpStatusCode? statusCode = null) : base(genericMessage) { - ArgumentGuard.NotNull(position, nameof(position)); + ArgumentNullException.ThrowIfNull(position); GenericMessage = genericMessage; SpecificMessage = specificMessage; diff --git a/src/JsonApiDotNetCore/Serialization/Request/NotSupportedExceptionExtensions.cs b/src/JsonApiDotNetCore/Serialization/Request/NotSupportedExceptionExtensions.cs new file mode 100644 index 0000000000..bf05471f50 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/NotSupportedExceptionExtensions.cs @@ -0,0 +1,91 @@ +using System.Text.Json.Serialization; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request; + +/// +/// A hacky approach to obtain the proper JSON:API source pointer from an exception thrown in a . +/// +/// +/// +/// This method relies on the behavior at +/// https://github.com/dotnet/runtime/blob/release/8.0/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonConverterOfT.ReadCore.cs#L100, +/// which wraps a thrown and adds the JSON path to the outer exception message, based on internal reader state. +/// +/// +/// To take advantage of this, we expect a custom converter to throw a with a specially-crafted +/// and a nested containing a relative source pointer and a captured stack trace. Once +/// all of that happens, this class extracts the added JSON path from the outer exception message and converts it to a JSON:API pointer to enrich the +/// nested with. +/// +/// +internal static class NotSupportedExceptionExtensions +{ + private const string LeadingText = " Path: "; + private const string TrailingText = " | LineNumber: "; + + public static bool HasJsonApiException(this NotSupportedException exception) + { + return exception.InnerException is NotSupportedException { InnerException: JsonApiException }; + } + + public static JsonApiException EnrichSourcePointer(this NotSupportedException exception) + { + var jsonApiException = (JsonApiException)exception.InnerException!.InnerException!; + string? sourcePointer = GetSourcePointerFromMessage(exception.Message); + + if (sourcePointer != null) + { + foreach (ErrorObject error in jsonApiException.Errors) + { + if (error.Source == null) + { + error.Source = new ErrorSource + { + Pointer = sourcePointer + }; + } + else + { + error.Source.Pointer = $"{sourcePointer}/{error.Source.Pointer}"; + } + } + } + + return jsonApiException; + } + + private static string? GetSourcePointerFromMessage(string message) + { + string? jsonPath = ExtractJsonPathFromMessage(message); + return JsonPathToSourcePointer(jsonPath); + } + + private static string? ExtractJsonPathFromMessage(string message) + { + int startIndex = message.IndexOf(LeadingText, StringComparison.Ordinal); + + if (startIndex != -1) + { + int stopIndex = message.IndexOf(TrailingText, startIndex, StringComparison.Ordinal); + + if (stopIndex != -1) + { + return message.Substring(startIndex + LeadingText.Length, stopIndex - startIndex - LeadingText.Length); + } + } + + return null; + } + + private static string? JsonPathToSourcePointer(string? jsonPath) + { + if (jsonPath != null && jsonPath.StartsWith('$')) + { + return jsonPath[1..].Replace('.', '/'); + } + + return null; + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Response/ETagGenerator.cs b/src/JsonApiDotNetCore/Serialization/Response/ETagGenerator.cs index 1352317575..e88d9c17d4 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ETagGenerator.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ETagGenerator.cs @@ -2,14 +2,14 @@ namespace JsonApiDotNetCore.Serialization.Response; -/// +/// internal sealed class ETagGenerator : IETagGenerator { private readonly IFingerprintGenerator _fingerprintGenerator; public ETagGenerator(IFingerprintGenerator fingerprintGenerator) { - ArgumentGuard.NotNull(fingerprintGenerator, nameof(fingerprintGenerator)); + ArgumentNullException.ThrowIfNull(fingerprintGenerator); _fingerprintGenerator = fingerprintGenerator; } @@ -17,7 +17,13 @@ public ETagGenerator(IFingerprintGenerator fingerprintGenerator) /// public EntityTagHeaderValue Generate(string requestUrl, string responseBody) { - string fingerprint = _fingerprintGenerator.Generate(ArrayFactory.Create(requestUrl, responseBody)); + string[] elements = + [ + requestUrl, + responseBody + ]; + + string fingerprint = _fingerprintGenerator.Generate(elements); string eTagValue = $"\"{fingerprint}\""; return EntityTagHeaderValue.Parse(eTagValue); 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 0eaee430c3..61f3349df3 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/FingerprintGenerator.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/FingerprintGenerator.cs @@ -3,10 +3,10 @@ namespace JsonApiDotNetCore.Serialization.Response; -/// +/// internal sealed class FingerprintGenerator : IFingerprintGenerator { - private static readonly byte[] Separator = Encoding.UTF8.GetBytes("|"); + private static readonly byte[] Separator = "|"u8.ToArray(); private static readonly uint[] LookupTable = Enumerable.Range(0, 256).Select(ToLookupEntry).ToArray(); private static uint ToLookupEntry(int index) @@ -18,7 +18,7 @@ private static uint ToLookupEntry(int index) /// public string Generate(IEnumerable elements) { - ArgumentGuard.NotNull(elements, nameof(elements)); + ArgumentNullException.ThrowIfNull(elements); using var hasher = IncrementalHash.CreateHash(HashAlgorithmName.MD5); diff --git a/src/JsonApiDotNetCore/Serialization/Response/IDocumentDescriptionLinkProvider.cs b/src/JsonApiDotNetCore/Serialization/Response/IDocumentDescriptionLinkProvider.cs new file mode 100644 index 0000000000..54a5ae36b3 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Response/IDocumentDescriptionLinkProvider.cs @@ -0,0 +1,17 @@ +using JsonApiDotNetCore.Configuration; + +namespace JsonApiDotNetCore.Serialization.Response; + +/// +/// Provides the value for the "describedby" link in https://jsonapi.org/format/#document-top-level. +/// +public interface IDocumentDescriptionLinkProvider +{ + /// + /// Gets the URL for the "describedby" link, or null when unavailable. + /// + /// + /// The returned URL can be absolute or relative. If possible, it gets converted based on . + /// + string? GetUrl(); +} diff --git a/src/JsonApiDotNetCore/Serialization/Response/IResponseModelAdapter.cs b/src/JsonApiDotNetCore/Serialization/Response/IResponseModelAdapter.cs index f458edf33a..49b0f65c95 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/IResponseModelAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/IResponseModelAdapter.cs @@ -12,32 +12,32 @@ public interface IResponseModelAdapter /// /// /// - /// ]]> + /// ]]> /// /// /// /// - /// + /// /// /// /// /// - /// + /// /// /// /// /// - /// ]]> + /// ]]> /// /// /// /// - /// ]]> + /// ]]> /// /// /// /// - /// + /// /// /// /// diff --git a/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs b/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs index 20f4ad242b..67bb61213b 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs @@ -11,42 +11,43 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; namespace JsonApiDotNetCore.Serialization.Response; -/// -public sealed class JsonApiWriter : IJsonApiWriter +/// +public sealed partial class JsonApiWriter : IJsonApiWriter { private readonly IJsonApiRequest _request; private readonly IJsonApiOptions _options; private readonly IResponseModelAdapter _responseModelAdapter; private readonly IExceptionHandler _exceptionHandler; private readonly IETagGenerator _eTagGenerator; - private readonly TraceLogWriter _traceWriter; + private readonly ILogger _logger; public JsonApiWriter(IJsonApiRequest request, IJsonApiOptions options, IResponseModelAdapter responseModelAdapter, IExceptionHandler exceptionHandler, - IETagGenerator eTagGenerator, ILoggerFactory loggerFactory) + IETagGenerator eTagGenerator, ILogger logger) { - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(responseModelAdapter, nameof(responseModelAdapter)); - ArgumentGuard.NotNull(exceptionHandler, nameof(exceptionHandler)); - ArgumentGuard.NotNull(eTagGenerator, nameof(eTagGenerator)); - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(responseModelAdapter); + ArgumentNullException.ThrowIfNull(exceptionHandler); + ArgumentNullException.ThrowIfNull(eTagGenerator); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(logger); _request = request; _options = options; _responseModelAdapter = responseModelAdapter; _exceptionHandler = exceptionHandler; _eTagGenerator = eTagGenerator; - _traceWriter = new TraceLogWriter(loggerFactory); + _logger = logger; } /// public async Task WriteAsync(object? model, HttpContext httpContext) { - ArgumentGuard.NotNull(httpContext, nameof(httpContext)); + ArgumentNullException.ThrowIfNull(httpContext); if (model == null && !CanWriteBody((HttpStatusCode)httpContext.Response.StatusCode)) { @@ -62,10 +63,15 @@ public async Task WriteAsync(object? model, HttpContext httpContext) return; } - _traceWriter.LogMessage(() => - $"Sending {httpContext.Response.StatusCode} response for {httpContext.Request.Method} request at '{httpContext.Request.GetEncodedUrl()}' with body: <<{responseBody}>>"); + if (_logger.IsEnabled(LogLevel.Trace)) + { + string requestMethod = httpContext.Request.Method.Replace(Environment.NewLine, ""); + string requestUrl = httpContext.Request.GetEncodedUrl(); + LogResponse(requestMethod, requestUrl, responseBody, httpContext.Response.StatusCode); + } - await SendResponseBodyAsync(httpContext.Response, responseBody); + var responseMediaType = new JsonApiMediaType(_request.Extensions); + await SendResponseBodyAsync(httpContext.Response, responseBody, responseMediaType.ToString()); } private static bool CanWriteBody(HttpStatusCode statusCode) @@ -137,7 +143,7 @@ private bool SetETagResponseHeader(HttpRequest request, HttpResponse response, s string url = request.GetEncodedUrl(); EntityTagHeaderValue responseETag = _eTagGenerator.Generate(url, responseContent); - response.Headers.Add(HeaderNames.ETag, responseETag.ToString()); + response.Headers.Append(HeaderNames.ETag, responseETag.ToString()); return RequestContainsMatchingETag(request.Headers, responseETag); } @@ -147,8 +153,8 @@ private bool SetETagResponseHeader(HttpRequest request, HttpResponse response, s private static bool RequestContainsMatchingETag(IHeaderDictionary requestHeaders, EntityTagHeaderValue responseETag) { - if (requestHeaders.Keys.Contains(HeaderNames.IfNoneMatch) && - EntityTagHeaderValue.TryParseList(requestHeaders[HeaderNames.IfNoneMatch], out IList? requestETags)) + if (requestHeaders.TryGetValue(HeaderNames.IfNoneMatch, out StringValues headerValues) && + EntityTagHeaderValue.TryParseList(headerValues, out IList? requestETags)) { foreach (EntityTagHeaderValue requestETag in requestETags) { @@ -162,11 +168,11 @@ private static bool RequestContainsMatchingETag(IHeaderDictionary requestHeaders return false; } - private async Task SendResponseBodyAsync(HttpResponse httpResponse, string? responseBody) + private async Task SendResponseBodyAsync(HttpResponse httpResponse, string? responseBody, string contentType) { if (!string.IsNullOrEmpty(responseBody)) { - httpResponse.ContentType = _request.Kind == EndpointKind.AtomicOperations ? HeaderConstants.AtomicOperationsMediaType : HeaderConstants.MediaType; + httpResponse.ContentType = contentType; using IDisposable _ = CodeTimingSessionManager.Current.Measure("Send response body"); @@ -175,4 +181,8 @@ private async Task SendResponseBodyAsync(HttpResponse httpResponse, string? resp await writer.FlushAsync(); } } + + [LoggerMessage(Level = LogLevel.Trace, SkipEnabledCheck = true, + Message = "Sending {ResponseStatusCode} response for {RequestMethod} request at '{RequestUrl}' with body: <<{ResponseBody}>>")] + private partial void LogResponse(string requestMethod, string requestUrl, string? responseBody, int responseStatusCode); } diff --git a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs index 0bad02066b..b7f200dd48 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs @@ -5,7 +5,7 @@ using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; @@ -15,6 +15,7 @@ namespace JsonApiDotNetCore.Serialization.Response; +/// [PublicAPI] public class LinkBuilder : ILinkBuilder { @@ -27,12 +28,16 @@ public class LinkBuilder : ILinkBuilder private static readonly string GetRelationshipControllerActionName = NoAsyncSuffix(nameof(BaseJsonApiController, int>.GetRelationshipAsync)); + private static readonly UriNormalizer UriNormalizer = new(); + private readonly IJsonApiOptions _options; private readonly IJsonApiRequest _request; private readonly IPaginationContext _paginationContext; private readonly IHttpContextAccessor _httpContextAccessor; private readonly LinkGenerator _linkGenerator; private readonly IControllerResourceMapping _controllerResourceMapping; + private readonly IPaginationParser _paginationParser; + private readonly IDocumentDescriptionLinkProvider _documentDescriptionLinkProvider; private HttpContext HttpContext { @@ -48,13 +53,16 @@ private HttpContext HttpContext } public LinkBuilder(IJsonApiOptions options, IJsonApiRequest request, IPaginationContext paginationContext, IHttpContextAccessor httpContextAccessor, - LinkGenerator linkGenerator, IControllerResourceMapping controllerResourceMapping) + LinkGenerator linkGenerator, IControllerResourceMapping controllerResourceMapping, IPaginationParser paginationParser, + IDocumentDescriptionLinkProvider documentDescriptionLinkProvider) { - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(paginationContext, nameof(paginationContext)); - ArgumentGuard.NotNull(linkGenerator, nameof(linkGenerator)); - ArgumentGuard.NotNull(controllerResourceMapping, nameof(controllerResourceMapping)); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(paginationContext); + ArgumentNullException.ThrowIfNull(linkGenerator); + ArgumentNullException.ThrowIfNull(controllerResourceMapping); + ArgumentNullException.ThrowIfNull(paginationParser); + ArgumentNullException.ThrowIfNull(documentDescriptionLinkProvider); _options = options; _request = request; @@ -62,6 +70,8 @@ public LinkBuilder(IJsonApiOptions options, IJsonApiRequest request, IPagination _httpContextAccessor = httpContextAccessor; _linkGenerator = linkGenerator; _controllerResourceMapping = controllerResourceMapping; + _paginationParser = paginationParser; + _documentDescriptionLinkProvider = documentDescriptionLinkProvider; } private static string NoAsyncSuffix(string actionName) @@ -80,16 +90,27 @@ private static string NoAsyncSuffix(string actionName) links.Self = GetLinkForTopLevelSelf(); } - if (_request.Kind == EndpointKind.Relationship && _request.Relationship != null && ShouldIncludeTopLevelLink(LinkTypes.Related, resourceType)) + if (_request is { Kind: EndpointKind.Relationship, Relationship: not null } && ShouldIncludeTopLevelLink(LinkTypes.Related, resourceType)) { links.Related = GetLinkForRelationshipRelated(_request.PrimaryId!, _request.Relationship); } - if (_request.IsCollection && _paginationContext.PageSize != null && ShouldIncludeTopLevelLink(LinkTypes.Paging, resourceType)) + if (_request.IsCollection && _paginationContext.PageSize != null && ShouldIncludeTopLevelLink(LinkTypes.Pagination, resourceType)) { SetPaginationInTopLevelLinks(resourceType!, links); } + if (ShouldIncludeTopLevelLink(LinkTypes.DescribedBy, resourceType)) + { + string? documentDescriptionUrl = _documentDescriptionLinkProvider.GetUrl(); + + if (!string.IsNullOrEmpty(documentDescriptionUrl)) + { + var requestUri = new Uri(HttpContext.Request.GetEncodedUrl()); + links.DescribedBy = UriNormalizer.Normalize(documentDescriptionUrl, _options.UseRelativeLinks, requestUri); + } + } + return links.HasValue() ? links : null; } @@ -140,20 +161,20 @@ private void SetPaginationInTopLevelLinks(ResourceType resourceType, TopLevelLin private string? CalculatePageSizeValue(PageSize? topPageSize, ResourceType resourceType) { - string pageSizeParameterValue = HttpContext.Request.Query[PageSizeParameterName]; + string? pageSizeParameterValue = HttpContext.Request.Query[PageSizeParameterName]; PageSize? newTopPageSize = Equals(topPageSize, _options.DefaultPageSize) ? null : topPageSize; return ChangeTopPageSize(pageSizeParameterValue, newTopPageSize, resourceType); } - private string? ChangeTopPageSize(string pageSizeParameterValue, PageSize? topPageSize, ResourceType resourceType) + private string? ChangeTopPageSize(string? pageSizeParameterValue, PageSize? topPageSize, ResourceType resourceType) { IImmutableList elements = ParsePageSizeExpression(pageSizeParameterValue, resourceType); int elementInTopScopeIndex = elements.FindIndex(expression => expression.Scope == null); if (topPageSize != null) { - var topPageSizeElement = new PaginationElementQueryStringValueExpression(null, topPageSize.Value); + var topPageSizeElement = new PaginationElementQueryStringValueExpression(null, topPageSize.Value, -1); elements = elementInTopScopeIndex != -1 ? elements.SetItem(elementInTopScopeIndex, topPageSizeElement) : elements.Insert(0, topPageSizeElement); } @@ -168,7 +189,7 @@ private void SetPaginationInTopLevelLinks(ResourceType resourceType, TopLevelLin string parameterValue = string.Join(',', elements.Select(expression => expression.Scope == null ? expression.Value.ToString() : $"{expression.Scope}:{expression.Value}")); - return parameterValue == string.Empty ? null : parameterValue; + return parameterValue.Length == 0 ? null : parameterValue; } private IImmutableList ParsePageSizeExpression(string? pageSizeParameterValue, ResourceType resourceType) @@ -178,10 +199,9 @@ private IImmutableList ParsePageSiz return ImmutableArray.Empty; } - var parser = new PaginationParser(); - PaginationQueryStringValueExpression paginationExpression = parser.Parse(pageSizeParameterValue, resourceType); + PaginationQueryStringValueExpression pagination = _paginationParser.Parse(pageSizeParameterValue, resourceType); - return paginationExpression.Elements; + return pagination.Elements; } private string GetLinkForPagination(int pageOffset, string? pageSizeValue) @@ -194,12 +214,12 @@ private string GetLinkForPagination(int pageOffset, string? pageSizeValue) }; UriComponents components = _options.UseRelativeLinks ? UriComponents.PathAndQuery : UriComponents.AbsoluteUri; - return builder.Uri.GetComponents(components, UriFormat.SafeUnescaped); + return builder.Uri.GetComponents(components, UriFormat.UriEscaped); } private string GetQueryStringInPaginationLink(int pageOffset, string? pageSizeValue) { - IDictionary parameters = HttpContext.Request.Query.ToDictionary(pair => pair.Key, pair => (string?)pair.Value.ToString()); + Dictionary parameters = HttpContext.Request.Query.ToDictionary(pair => pair.Key, pair => (string?)pair.Value.ToString()); if (pageSizeValue == null) { @@ -225,8 +245,8 @@ private string GetQueryStringInPaginationLink(int pageOffset, string? pageSizeVa /// public ResourceLinks? GetResourceLinks(ResourceType resourceType, IIdentifiable resource) { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - ArgumentGuard.NotNull(resource, nameof(resource)); + ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(resource); var links = new ResourceLinks(); @@ -255,7 +275,7 @@ private bool ShouldIncludeResourceLink(LinkTypes linkType, ResourceType resource private string? GetLinkForResourceSelf(ResourceType resourceType, IIdentifiable resource) { string? controllerName = _controllerResourceMapping.GetControllerNameForResourceType(resourceType); - IDictionary routeValues = GetRouteValues(resource.StringId!, null); + RouteValueDictionary routeValues = GetRouteValues(resource.StringId!, null); return RenderLinkForAction(controllerName, GetPrimaryControllerActionName, routeValues); } @@ -263,8 +283,8 @@ private bool ShouldIncludeResourceLink(LinkTypes linkType, ResourceType resource /// public RelationshipLinks? GetRelationshipLinks(RelationshipAttribute relationship, IIdentifiable leftResource) { - ArgumentGuard.NotNull(relationship, nameof(relationship)); - ArgumentGuard.NotNull(leftResource, nameof(leftResource)); + ArgumentNullException.ThrowIfNull(relationship); + ArgumentNullException.ThrowIfNull(leftResource); var links = new RelationshipLinks(); @@ -284,7 +304,7 @@ private bool ShouldIncludeResourceLink(LinkTypes linkType, ResourceType resource private string? GetLinkForRelationshipSelf(string leftId, RelationshipAttribute relationship) { string? controllerName = _controllerResourceMapping.GetControllerNameForResourceType(relationship.LeftType); - IDictionary routeValues = GetRouteValues(leftId, relationship.PublicName); + RouteValueDictionary routeValues = GetRouteValues(leftId, relationship.PublicName); return RenderLinkForAction(controllerName, GetRelationshipControllerActionName, routeValues); } @@ -292,12 +312,12 @@ private bool ShouldIncludeResourceLink(LinkTypes linkType, ResourceType resource private string? GetLinkForRelationshipRelated(string leftId, RelationshipAttribute relationship) { string? controllerName = _controllerResourceMapping.GetControllerNameForResourceType(relationship.LeftType); - IDictionary routeValues = GetRouteValues(leftId, relationship.PublicName); + RouteValueDictionary routeValues = GetRouteValues(leftId, relationship.PublicName); return RenderLinkForAction(controllerName, GetSecondaryControllerActionName, routeValues); } - private IDictionary GetRouteValues(string primaryId, string? relationshipName) + private RouteValueDictionary GetRouteValues(string primaryId, string? relationshipName) { // By default, we copy all route parameters from the *current* endpoint, which helps in case all endpoints have the same // set of non-standard parameters. There is no way we can know which non-standard parameters a *different* endpoint needs, @@ -312,6 +332,9 @@ private bool ShouldIncludeResourceLink(LinkTypes linkType, ResourceType resource protected virtual string? RenderLinkForAction(string? controllerName, string actionName, IDictionary routeValues) { + ArgumentNullException.ThrowIfNull(actionName); + ArgumentNullException.ThrowIfNull(routeValues); + if (controllerName == null) { // When passing null to LinkGenerator, it uses the controller for the current endpoint. This is incorrect for diff --git a/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs index 91ec62387c..1c3cc604e3 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 { @@ -12,13 +12,13 @@ public sealed class MetaBuilder : IMetaBuilder private readonly IJsonApiOptions _options; private readonly IResponseMeta _responseMeta; - private Dictionary _meta = new(); + private Dictionary _meta = []; public MetaBuilder(IPaginationContext paginationContext, IJsonApiOptions options, IResponseMeta responseMeta) { - ArgumentGuard.NotNull(paginationContext, nameof(paginationContext)); - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(responseMeta, nameof(responseMeta)); + ArgumentNullException.ThrowIfNull(paginationContext); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(responseMeta); _paginationContext = paginationContext; _options = options; @@ -28,9 +28,9 @@ public MetaBuilder(IPaginationContext paginationContext, IJsonApiOptions options /// public void Add(IDictionary values) { - ArgumentGuard.NotNull(values, nameof(values)); + ArgumentNullException.ThrowIfNull(values); - _meta = values.Keys.Union(_meta.Keys).ToDictionary(key => key, key => values.ContainsKey(key) ? values[key] : _meta[key]); + _meta = values.Keys.Union(_meta.Keys).ToDictionary(key => key, key => values.TryGetValue(key, out object? value) ? value : _meta[key]); } /// @@ -50,6 +50,6 @@ public void Add(IDictionary values) Add(extraMeta); } - return _meta.Any() ? _meta : null; + return _meta.Count > 0 ? _meta : null; } } diff --git a/src/JsonApiDotNetCore/Serialization/Response/NoDocumentDescriptionLinkProvider.cs b/src/JsonApiDotNetCore/Serialization/Response/NoDocumentDescriptionLinkProvider.cs new file mode 100644 index 0000000000..c419e1ae35 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Response/NoDocumentDescriptionLinkProvider.cs @@ -0,0 +1,15 @@ +namespace JsonApiDotNetCore.Serialization.Response; + +/// +/// Provides no value for the "describedby" link in https://jsonapi.org/format/#document-top-level. +/// +public sealed class NoDocumentDescriptionLinkProvider : IDocumentDescriptionLinkProvider +{ + /// + /// Always returns null. + /// + public string? GetUrl() + { + return null; + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs index aed19d9097..2ded4ae896 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'. @@ -37,9 +37,9 @@ internal sealed class ResourceObjectTreeNode : IEquatable(); + _directChildren ??= []; _directChildren.Add(treeNode); } public void EnsureHasRelationship(RelationshipAttribute relationship) { - ArgumentGuard.NotNull(relationship, nameof(relationship)); + ArgumentNullException.ThrowIfNull(relationship); - _childrenByRelationship ??= new Dictionary>(); + _childrenByRelationship ??= []; if (!_childrenByRelationship.ContainsKey(relationship)) { - _childrenByRelationship[relationship] = new HashSet(); + _childrenByRelationship[relationship] = []; } } public void AttachRelationshipChild(RelationshipAttribute relationship, ResourceObjectTreeNode rightNode) { - ArgumentGuard.NotNull(relationship, nameof(relationship)); - ArgumentGuard.NotNull(rightNode, nameof(rightNode)); + ArgumentNullException.ThrowIfNull(relationship); + ArgumentNullException.ThrowIfNull(rightNode); if (_childrenByRelationship == null) { @@ -92,11 +92,11 @@ public IReadOnlySet GetUniqueNodes() { AssertIsTreeRoot(); - var visited = new HashSet(); + HashSet visited = []; VisitSubtree(this, visited); - return visited; + return visited.AsReadOnly(); } private static void VisitSubtree(ResourceObjectTreeNode treeNode, ISet visited) @@ -151,7 +151,7 @@ private static void VisitRelationshipChildInSubtree(HashSet? GetRightNodesInRelationship(RelationshipAttribute relationship) { return _childrenByRelationship != null && _childrenByRelationship.TryGetValue(relationship, out HashSet? rightNodes) - ? rightNodes + ? rightNodes.AsReadOnly() : null; } @@ -162,7 +162,7 @@ public IReadOnlyList GetResponseData() { AssertIsTreeRoot(); - return GetDirectChildren().Select(child => child.ResourceObject).ToArray(); + return GetDirectChildren().Select(child => child.ResourceObject).ToArray().AsReadOnly(); } /// @@ -174,14 +174,25 @@ public IList GetResponseIncluded() { AssertIsTreeRoot(); - var visited = new HashSet(); + HashSet visited = []; foreach (ResourceObjectTreeNode child in GetDirectChildren()) { VisitRelationshipChildrenInSubtree(child, visited); } - return visited.Select(node => node.ResourceObject).ToArray(); + HashSet primaryResourceObjectSet = GetDirectChildren().Select(node => node.ResourceObject).ToHashSet(ResourceObjectComparer.Instance); + List includes = []; + + foreach (ResourceObject include in visited.Select(node => node.ResourceObject)) + { + if (!primaryResourceObjectSet.Contains(include)) + { + includes.Add(include); + } + } + + return includes; } private IList GetDirectChildren() @@ -199,7 +210,7 @@ private void AssertIsTreeRoot() public bool Equals(ResourceObjectTreeNode? other) { - if (ReferenceEquals(null, other)) + if (other is null) { return false; } diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs index 223166e59e..80f7809255 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs @@ -5,22 +5,19 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal; using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Resources.Internal; using JsonApiDotNetCore.Serialization.Objects; namespace JsonApiDotNetCore.Serialization.Response; -/// +/// [PublicAPI] public class ResponseModelAdapter : IResponseModelAdapter { - private static readonly CollectionConverter CollectionConverter = new(); - private readonly IJsonApiRequest _request; private readonly IJsonApiOptions _options; private readonly ILinkBuilder _linkBuilder; @@ -37,14 +34,14 @@ public ResponseModelAdapter(IJsonApiRequest request, IJsonApiOptions options, IL IResourceDefinitionAccessor resourceDefinitionAccessor, IEvaluatedIncludeCache evaluatedIncludeCache, ISparseFieldSetCache sparseFieldSetCache, IRequestQueryStringAccessor requestQueryStringAccessor) { - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(linkBuilder, nameof(linkBuilder)); - ArgumentGuard.NotNull(metaBuilder, nameof(metaBuilder)); - ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); - ArgumentGuard.NotNull(evaluatedIncludeCache, nameof(evaluatedIncludeCache)); - ArgumentGuard.NotNull(sparseFieldSetCache, nameof(sparseFieldSetCache)); - ArgumentGuard.NotNull(requestQueryStringAccessor, nameof(requestQueryStringAccessor)); + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(linkBuilder); + ArgumentNullException.ThrowIfNull(metaBuilder); + ArgumentNullException.ThrowIfNull(resourceDefinitionAccessor); + ArgumentNullException.ThrowIfNull(evaluatedIncludeCache); + ArgumentNullException.ThrowIfNull(sparseFieldSetCache); + ArgumentNullException.ThrowIfNull(requestQueryStringAccessor); _request = request; _options = options; @@ -104,11 +101,12 @@ public Document Convert(object? model) } else if (model is IEnumerable errorObjects) { - document.Errors = errorObjects.ToArray(); + document.Errors = errorObjects.ToList(); } else if (model is ErrorObject errorObject) { - document.Errors = errorObject.AsArray(); + List errors = [errorObject]; + document.Errors = errors; } else { @@ -125,6 +123,8 @@ public Document Convert(object? model) protected virtual AtomicResultObject ConvertOperation(OperationContainer? operation, IImmutableSet includeElements) { + ArgumentNullException.ThrowIfNull(includeElements); + ResourceObject? resourceObject = null; if (operation != null) @@ -206,6 +206,9 @@ private static ResourceType GetEffectiveResourceType(IIdentifiable resource, Res protected virtual ResourceObject ConvertResource(IIdentifiable resource, ResourceType resourceType, EndpointKind kind) { + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(resourceType); + bool isRelationship = kind == EndpointKind.Relationship; if (!isRelationship) @@ -236,6 +239,10 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc #pragma warning restore AV1130 // Return type in method signature should be an interface to an unchangeable collection IImmutableSet fieldSet) { + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(fieldSet); + var attrMap = new Dictionary(resourceType.Attributes.Count); foreach (AttrAttribute attr in resourceType.Attributes) @@ -261,7 +268,7 @@ protected virtual ResourceObject ConvertResource(IIdentifiable resource, Resourc attrMap.Add(attr.PublicName, value); } - return attrMap.Any() ? attrMap : null; + return attrMap.Count > 0 ? attrMap : null; } private void TraverseRelationships(IIdentifiable leftResource, ResourceObjectTreeNode leftTreeNode, IImmutableSet includeElements, @@ -287,8 +294,15 @@ private void TraverseRelationship(RelationshipAttribute relationship, IIdentifia ? leftTreeNode.ResourceType.GetRelationshipByPropertyName(relationship.Property.Name) : relationship; + if (effectiveRelationship.IsViewBlocked()) + { + // Hide related resources when blocked. According to JSON:API, breaking full linkage is only allowed + // when the client explicitly requested it by sending a sparse fieldset. + return; + } + object? rightValue = effectiveRelationship.GetValue(leftResource); - IReadOnlyCollection rightResources = CollectionConverter.ExtractResources(rightValue); + IReadOnlyCollection rightResources = CollectionConverter.Instance.ExtractResources(rightValue); leftTreeNode.EnsureHasRelationship(effectiveRelationship); @@ -387,7 +401,7 @@ private static SingleOrManyData GetRelationshipData(Re { IList resourceObjects = rootNode.GetResponseIncluded(); - if (resourceObjects.Any()) + if (resourceObjects.Count > 0) { return resourceObjects; } diff --git a/src/JsonApiDotNetCore/Serialization/Response/UriNormalizer.cs b/src/JsonApiDotNetCore/Serialization/Response/UriNormalizer.cs new file mode 100644 index 0000000000..f0d9a7400d --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Response/UriNormalizer.cs @@ -0,0 +1,75 @@ +namespace JsonApiDotNetCore.Serialization.Response; + +internal sealed class UriNormalizer +{ + /// + /// Converts a URL to absolute or relative format, if possible. + /// + /// + /// The absolute or relative URL to normalize. + /// + /// + /// Whether to convert to absolute or relative format. + /// + /// + /// The URL of the current HTTP request, whose path and query string are discarded. + /// + public string Normalize(string sourceUrl, bool preferRelative, Uri requestUri) + { + var sourceUri = new Uri(sourceUrl, UriKind.RelativeOrAbsolute); + Uri baseUri = RemovePathFromAbsoluteUri(requestUri); + + if (!sourceUri.IsAbsoluteUri && !preferRelative) + { + var absoluteUri = new Uri(baseUri, sourceUrl); + return absoluteUri.AbsoluteUri; + } + + if (sourceUri.IsAbsoluteUri && preferRelative) + { + if (AreSameServer(baseUri, sourceUri)) + { + Uri relativeUri = baseUri.MakeRelativeUri(sourceUri); + return relativeUri.ToString(); + } + } + + return sourceUrl; + } + + private static Uri RemovePathFromAbsoluteUri(Uri uri) + { + var requestUriBuilder = new UriBuilder(uri) + { + Path = null + }; + + return requestUriBuilder.Uri; + } + + private static bool AreSameServer(Uri left, Uri right) + { + // Custom implementation because Uri.Equals() ignores the casing of username/password. + + string leftScheme = left.GetComponents(UriComponents.Scheme, UriFormat.UriEscaped); + string rightScheme = right.GetComponents(UriComponents.Scheme, UriFormat.UriEscaped); + + if (!string.Equals(leftScheme, rightScheme, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + string leftServer = left.GetComponents(UriComponents.HostAndPort, UriFormat.UriEscaped); + string rightServer = right.GetComponents(UriComponents.HostAndPort, UriFormat.UriEscaped); + + if (!string.Equals(leftServer, rightServer, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + string leftUserInfo = left.GetComponents(UriComponents.UserInfo, UriFormat.UriEscaped); + string rightUserInfo = right.GetComponents(UriComponents.UserInfo, UriFormat.UriEscaped); + + return leftUserInfo == rightUserInfo; + } +} diff --git a/src/JsonApiDotNetCore/Services/AsyncCollectionExtensions.cs b/src/JsonApiDotNetCore/Services/AsyncCollectionExtensions.cs index 3924999f63..83ba8902a2 100644 --- a/src/JsonApiDotNetCore/Services/AsyncCollectionExtensions.cs +++ b/src/JsonApiDotNetCore/Services/AsyncCollectionExtensions.cs @@ -7,8 +7,8 @@ public static class AsyncCollectionExtensions { public static async Task AddRangeAsync(this ICollection source, IAsyncEnumerable elementsToAdd, CancellationToken cancellationToken = default) { - ArgumentGuard.NotNull(source, nameof(source)); - ArgumentGuard.NotNull(elementsToAdd, nameof(elementsToAdd)); + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(elementsToAdd); await foreach (T missingResource in elementsToAdd.WithCancellation(cancellationToken)) { @@ -18,9 +18,9 @@ public static async Task AddRangeAsync(this ICollection source, IAsyncEnum public static async Task> ToListAsync(this IAsyncEnumerable source, CancellationToken cancellationToken = default) { - ArgumentGuard.NotNull(source, nameof(source)); + ArgumentNullException.ThrowIfNull(source); - var list = new List(); + List list = []; await foreach (T element in source.WithCancellation(cancellationToken)) { diff --git a/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs b/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs index 58fb122a50..07c9234513 100644 --- a/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; @@ -25,5 +26,6 @@ public interface IAddToRelationshipService /// /// Propagates notification that request handling should be canceled. /// - Task AddToToManyRelationshipAsync(TId leftId, string relationshipName, ISet rightResourceIds, CancellationToken cancellationToken); + Task AddToToManyRelationshipAsync([DisallowNull] TId leftId, string relationshipName, ISet rightResourceIds, + CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/Services/IDeleteService.cs b/src/JsonApiDotNetCore/Services/IDeleteService.cs index 9bdfcd143b..375181e529 100644 --- a/src/JsonApiDotNetCore/Services/IDeleteService.cs +++ b/src/JsonApiDotNetCore/Services/IDeleteService.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using JsonApiDotNetCore.Resources; // ReSharper disable UnusedTypeParameter @@ -11,5 +12,5 @@ public interface IDeleteService /// /// Handles a JSON:API request to delete an existing resource. /// - Task DeleteAsync(TId id, CancellationToken cancellationToken); + Task DeleteAsync([DisallowNull] TId id, CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/Services/IGetByIdService.cs b/src/JsonApiDotNetCore/Services/IGetByIdService.cs index 4bf34788eb..fc95e0af1e 100644 --- a/src/JsonApiDotNetCore/Services/IGetByIdService.cs +++ b/src/JsonApiDotNetCore/Services/IGetByIdService.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using JsonApiDotNetCore.Resources; namespace JsonApiDotNetCore.Services; @@ -9,5 +10,5 @@ public interface IGetByIdService /// /// Handles a JSON:API request to retrieve a single resource for a primary endpoint. /// - Task GetAsync(TId id, CancellationToken cancellationToken); + Task GetAsync([DisallowNull] TId id, CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/Services/IGetRelationshipService.cs b/src/JsonApiDotNetCore/Services/IGetRelationshipService.cs index afd284a7ce..34b9880bea 100644 --- a/src/JsonApiDotNetCore/Services/IGetRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IGetRelationshipService.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using JsonApiDotNetCore.Resources; // ReSharper disable UnusedTypeParameter @@ -11,5 +12,5 @@ public interface IGetRelationshipService /// /// Handles a JSON:API request to retrieve a single relationship. /// - Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken); + Task GetRelationshipAsync([DisallowNull] TId id, string relationshipName, CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/Services/IGetSecondaryService.cs b/src/JsonApiDotNetCore/Services/IGetSecondaryService.cs index 9f8c528552..33d47db454 100644 --- a/src/JsonApiDotNetCore/Services/IGetSecondaryService.cs +++ b/src/JsonApiDotNetCore/Services/IGetSecondaryService.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using JsonApiDotNetCore.Resources; // ReSharper disable UnusedTypeParameter @@ -12,5 +13,5 @@ public interface IGetSecondaryService /// Handles a JSON:API request to retrieve a single resource or a collection of resources for a secondary endpoint, such as /articles/1/author or /// /articles/1/revisions. /// - Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken); + Task GetSecondaryAsync([DisallowNull] TId id, string relationshipName, CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs b/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs index cb572801bb..d3844610c7 100644 --- a/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using JsonApiDotNetCore.Resources; // ReSharper disable UnusedTypeParameter @@ -23,5 +24,6 @@ public interface IRemoveFromRelationshipService /// /// Propagates notification that request handling should be canceled. /// - Task RemoveFromToManyRelationshipAsync(TId leftId, string relationshipName, ISet rightResourceIds, CancellationToken cancellationToken); + Task RemoveFromToManyRelationshipAsync([DisallowNull] TId leftId, string relationshipName, ISet rightResourceIds, + CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/Services/IResourceCommandService.cs b/src/JsonApiDotNetCore/Services/IResourceCommandService.cs index 6c6dc408c9..7bc47b6c20 100644 --- a/src/JsonApiDotNetCore/Services/IResourceCommandService.cs +++ b/src/JsonApiDotNetCore/Services/IResourceCommandService.cs @@ -14,6 +14,4 @@ namespace JsonApiDotNetCore.Services; public interface IResourceCommandService : ICreateService, IAddToRelationshipService, IUpdateService, ISetRelationshipService, IDeleteService, IRemoveFromRelationshipService - where TResource : class, IIdentifiable -{ -} + where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/Services/IResourceQueryService.cs b/src/JsonApiDotNetCore/Services/IResourceQueryService.cs index 7c9d32071f..b2de9b03fc 100644 --- a/src/JsonApiDotNetCore/Services/IResourceQueryService.cs +++ b/src/JsonApiDotNetCore/Services/IResourceQueryService.cs @@ -13,6 +13,4 @@ namespace JsonApiDotNetCore.Services; /// public interface IResourceQueryService : IGetAllService, IGetByIdService, IGetRelationshipService, IGetSecondaryService - where TResource : class, IIdentifiable -{ -} + where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/Services/IResourceService.cs b/src/JsonApiDotNetCore/Services/IResourceService.cs index 87637e53a2..2a75be7151 100644 --- a/src/JsonApiDotNetCore/Services/IResourceService.cs +++ b/src/JsonApiDotNetCore/Services/IResourceService.cs @@ -12,6 +12,4 @@ namespace JsonApiDotNetCore.Services; /// The resource identifier type. /// public interface IResourceService : IResourceCommandService, IResourceQueryService - where TResource : class, IIdentifiable -{ -} + where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs b/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs index 3050394beb..05e8c8c606 100644 --- a/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using JsonApiDotNetCore.Resources; // ReSharper disable UnusedTypeParameter @@ -23,5 +24,5 @@ public interface ISetRelationshipService /// /// Propagates notification that request handling should be canceled. /// - Task SetRelationshipAsync(TId leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken); + Task SetRelationshipAsync([DisallowNull] TId leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/Services/IUpdateService.cs b/src/JsonApiDotNetCore/Services/IUpdateService.cs index f742a1fc2e..b8349c3909 100644 --- a/src/JsonApiDotNetCore/Services/IUpdateService.cs +++ b/src/JsonApiDotNetCore/Services/IUpdateService.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using JsonApiDotNetCore.Resources; namespace JsonApiDotNetCore.Services; @@ -10,5 +11,5 @@ public interface IUpdateService /// Handles a JSON:API request to update the attributes and/or relationships of an existing resource. Only the values of sent attributes are replaced. /// And only the values of sent relationships are replaced. /// - Task UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken); + Task UpdateAsync([DisallowNull] TId id, TResource resource, CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 0cca8b92b9..de5d3b7b1f 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -1,4 +1,5 @@ using System.Collections; +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; @@ -15,12 +16,11 @@ namespace JsonApiDotNetCore.Services; -/// +/// [PublicAPI] public class JsonApiResourceService : IResourceService where TResource : class, IIdentifiable { - private readonly CollectionConverter _collectionConverter = new(); private readonly IResourceRepositoryAccessor _repositoryAccessor; private readonly IQueryLayerComposer _queryLayerComposer; private readonly IPaginationContext _paginationContext; @@ -34,14 +34,14 @@ public JsonApiResourceService(IResourceRepositoryAccessor repositoryAccessor, IQ IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceDefinitionAccessor resourceDefinitionAccessor) { - ArgumentGuard.NotNull(repositoryAccessor, nameof(repositoryAccessor)); - ArgumentGuard.NotNull(queryLayerComposer, nameof(queryLayerComposer)); - ArgumentGuard.NotNull(paginationContext, nameof(paginationContext)); - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(resourceChangeTracker, nameof(resourceChangeTracker)); - ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); + ArgumentNullException.ThrowIfNull(repositoryAccessor); + ArgumentNullException.ThrowIfNull(queryLayerComposer); + ArgumentNullException.ThrowIfNull(paginationContext); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(loggerFactory); + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(resourceChangeTracker); + ArgumentNullException.ThrowIfNull(resourceDefinitionAccessor); _repositoryAccessor = repositoryAccessor; _queryLayerComposer = queryLayerComposer; @@ -58,10 +58,10 @@ public virtual async Task> GetAsync(CancellationT { _traceWriter.LogMethodStart(); - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get resources"); - AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get resources"); + if (_options.IncludeTotalResourceCount) { FilterExpression? topFilter = _queryLayerComposer.GetPrimaryFilterFromConstraints(_request.PrimaryResourceType); @@ -85,7 +85,7 @@ public virtual async Task> GetAsync(CancellationT } /// - public virtual async Task GetAsync(TId id, CancellationToken cancellationToken) + public virtual async Task GetAsync([DisallowNull] TId id, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -98,7 +98,7 @@ public virtual async Task GetAsync(TId id, CancellationToken cancella } /// - public virtual async Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) + public virtual async Task GetSecondaryAsync([DisallowNull] TId id, string relationshipName, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -106,11 +106,12 @@ public virtual async Task GetAsync(TId id, CancellationToken cancella relationshipName }); - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get secondary resource(s)"); - + ArgumentNullException.ThrowIfNull(relationshipName); AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); AssertHasRelationship(_request.Relationship, relationshipName); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get secondary resource(s)"); + if (_options.IncludeTotalResourceCount && _request.IsCollection) { await RetrieveResourceCountForNonPrimaryEndpointAsync(id, (HasManyAttribute)_request.Relationship, cancellationToken); @@ -137,7 +138,7 @@ public virtual async Task GetAsync(TId id, CancellationToken cancella } /// - public virtual async Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) + public virtual async Task GetRelationshipAsync([DisallowNull] TId id, string relationshipName, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -145,13 +146,12 @@ public virtual async Task GetAsync(TId id, CancellationToken cancella relationshipName }); - ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); - - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get relationship"); - + ArgumentNullException.ThrowIfNull(relationshipName); AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); AssertHasRelationship(_request.Relationship, relationshipName); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get relationship"); + if (_options.IncludeTotalResourceCount && _request.IsCollection) { await RetrieveResourceCountForNonPrimaryEndpointAsync(id, (HasManyAttribute)_request.Relationship, cancellationToken); @@ -177,7 +177,8 @@ public virtual async Task GetAsync(TId id, CancellationToken cancella return rightValue; } - private async Task RetrieveResourceCountForNonPrimaryEndpointAsync(TId id, HasManyAttribute relationship, CancellationToken cancellationToken) + private async Task RetrieveResourceCountForNonPrimaryEndpointAsync([DisallowNull] TId id, HasManyAttribute relationship, + CancellationToken cancellationToken) { FilterExpression? secondaryFilter = _queryLayerComposer.GetSecondaryFilterFromConstraints(id, relationship); @@ -195,7 +196,7 @@ private async Task RetrieveResourceCountForNonPrimaryEndpointAsync(TId id, HasMa resource }); - ArgumentGuard.NotNull(resource, nameof(resource)); + ArgumentNullException.ThrowIfNull(resource); using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Create resource"); @@ -205,7 +206,10 @@ private async Task RetrieveResourceCountForNonPrimaryEndpointAsync(TId id, HasMa await AccurizeResourceTypesInHierarchyToAssignInRelationshipsAsync(resourceFromRequest, cancellationToken); Type resourceClrType = resourceFromRequest.GetClrType(); - TResource resourceForDatabase = await _repositoryAccessor.GetForCreateAsync(resourceClrType, resourceFromRequest.Id, cancellationToken); + + TResource resourceForDatabase = + await _repositoryAccessor.GetForCreateAsync(resourceClrType, resourceFromRequest.Id!, cancellationToken); + AccurizeJsonApiRequest(resourceForDatabase); _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceForDatabase); @@ -223,7 +227,7 @@ private async Task RetrieveResourceCountForNonPrimaryEndpointAsync(TId id, HasMa throw; } - TResource resourceFromDatabase = await GetPrimaryResourceByIdAsync(resourceForDatabase.Id, TopFieldSelection.WithAllAttributes, cancellationToken); + TResource resourceFromDatabase = await GetPrimaryResourceByIdAsync(resourceForDatabase.Id!, TopFieldSelection.WithAllAttributes, cancellationToken); _resourceChangeTracker.SetFinallyStoredAttributeValues(resourceFromDatabase); @@ -233,9 +237,11 @@ private async Task RetrieveResourceCountForNonPrimaryEndpointAsync(TId id, HasMa protected async Task AssertPrimaryResourceDoesNotExistAsync(TResource resource, CancellationToken cancellationToken) { + ArgumentNullException.ThrowIfNull(resource); + if (!Equals(resource.Id, default(TId))) { - TResource? existingResource = await GetPrimaryResourceByIdOrDefaultAsync(resource.Id, TopFieldSelection.OnlyIdAttribute, cancellationToken); + TResource? existingResource = await GetPrimaryResourceByIdOrDefaultAsync(resource.Id!, TopFieldSelection.OnlyIdAttribute, cancellationToken); if (existingResource != null) { @@ -246,6 +252,8 @@ protected async Task AssertPrimaryResourceDoesNotExistAsync(TResource resource, protected virtual async Task InitializeResourceAsync(TResource resourceForDatabase, CancellationToken cancellationToken) { + ArgumentNullException.ThrowIfNull(resourceForDatabase); + await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceForDatabase, WriteOperationKind.CreateResource, cancellationToken); } @@ -256,22 +264,24 @@ private async Task AccurizeResourceTypesInHierarchyToAssignInRelationshipsAsync( protected async Task AssertResourcesToAssignInRelationshipsExistAsync(TResource primaryResource, CancellationToken cancellationToken) { + ArgumentNullException.ThrowIfNull(primaryResource); + await ValidateResourcesToAssignInRelationshipsExistWithRefreshAsync(primaryResource, false, cancellationToken); } private async Task ValidateResourcesToAssignInRelationshipsExistWithRefreshAsync(TResource primaryResource, bool onlyIfTypeHierarchy, CancellationToken cancellationToken) { - var missingResources = new List(); + List missingResources = []; foreach ((QueryLayer queryLayer, RelationshipAttribute relationship) in _queryLayerComposer.ComposeForGetTargetedSecondaryResourceIds(primaryResource)) { if (!onlyIfTypeHierarchy || relationship.RightType.IsPartOfTypeHierarchy()) { object? rightValue = relationship.GetValue(primaryResource); - HashSet rightResourceIds = _collectionConverter.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); + HashSet rightResourceIds = CollectionConverter.Instance.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); - if (rightResourceIds.Any()) + if (rightResourceIds.Count > 0) { IAsyncEnumerable missingResourcesInRelationship = GetMissingRightResourcesAsync(queryLayer, relationship, rightResourceIds, cancellationToken); @@ -282,21 +292,21 @@ private async Task ValidateResourcesToAssignInRelationshipsExistWithRefreshAsync // Now that we've fetched them, update the request types so that resource definitions observe the actually stored types. object? newRightValue = relationship is HasOneAttribute ? rightResourceIds.FirstOrDefault() - : _collectionConverter.CopyToTypedCollection(rightResourceIds, relationship.Property.PropertyType); + : CollectionConverter.Instance.CopyToTypedCollection(rightResourceIds, relationship.Property.PropertyType); relationship.SetValue(primaryResource, newRightValue); } } } - if (missingResources.Any()) + if (missingResources.Count > 0) { throw new ResourcesInRelationshipsNotFoundException(missingResources); } } private async IAsyncEnumerable GetMissingRightResourcesAsync(QueryLayer existingRightResourceIdsQueryLayer, - RelationshipAttribute relationship, ISet rightResourceIds, [EnumeratorCancellation] CancellationToken cancellationToken) + RelationshipAttribute relationship, HashSet rightResourceIds, [EnumeratorCancellation] CancellationToken cancellationToken) { IReadOnlyCollection existingResources = await _repositoryAccessor.GetAsync(existingRightResourceIdsQueryLayer.ResourceType, existingRightResourceIdsQueryLayer, cancellationToken); @@ -329,7 +339,7 @@ private async IAsyncEnumerable GetMissingRightRes } /// - public virtual async Task AddToToManyRelationshipAsync(TId leftId, string relationshipName, ISet rightResourceIds, + public virtual async Task AddToToManyRelationshipAsync([DisallowNull] TId leftId, string relationshipName, ISet rightResourceIds, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new @@ -339,16 +349,15 @@ public virtual async Task AddToToManyRelationshipAsync(TId leftId, string relati rightResourceIds }); - ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); - ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); + ArgumentNullException.ThrowIfNull(relationshipName); + ArgumentNullException.ThrowIfNull(rightResourceIds); + AssertHasRelationship(_request.Relationship, relationshipName); using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Add to to-many relationship"); - AssertHasRelationship(_request.Relationship, relationshipName); - TResource? resourceFromDatabase = null; - if (rightResourceIds.Any() && _request.Relationship is HasManyAttribute { IsManyToMany: true } manyToManyRelationship) + if (rightResourceIds.Count > 0 && _request.Relationship is HasManyAttribute { IsManyToMany: true } manyToManyRelationship) { // In the case of a many-to-many relationship, creating a duplicate entry in the join table results in a // unique constraint violation. We avoid that by excluding already-existing entries from the set in advance. @@ -385,21 +394,21 @@ public virtual async Task AddToToManyRelationshipAsync(TId leftId, string relati } } - private async Task RemoveExistingIdsFromRelationshipRightSideAsync(HasManyAttribute hasManyRelationship, TId leftId, + private async Task RemoveExistingIdsFromRelationshipRightSideAsync(HasManyAttribute hasManyRelationship, [DisallowNull] TId leftId, ISet rightResourceIds, CancellationToken cancellationToken) { TResource leftResource = await GetForHasManyUpdateAsync(hasManyRelationship, leftId, rightResourceIds, cancellationToken); object? rightValue = hasManyRelationship.GetValue(leftResource); - IReadOnlyCollection existingRightResourceIds = _collectionConverter.ExtractResources(rightValue); + IReadOnlyCollection existingRightResourceIds = CollectionConverter.Instance.ExtractResources(rightValue); rightResourceIds.ExceptWith(existingRightResourceIds); return leftResource; } - private async Task GetForHasManyUpdateAsync(HasManyAttribute hasManyRelationship, TId leftId, ISet rightResourceIds, - CancellationToken cancellationToken) + private async Task GetForHasManyUpdateAsync(HasManyAttribute hasManyRelationship, [DisallowNull] TId leftId, + ISet rightResourceIds, CancellationToken cancellationToken) { QueryLayer queryLayer = _queryLayerComposer.ComposeForHasMany(hasManyRelationship, leftId, rightResourceIds); var leftResource = await _repositoryAccessor.GetForUpdateAsync(queryLayer, cancellationToken); @@ -412,10 +421,10 @@ private async Task GetForHasManyUpdateAsync(HasManyAttribute hasManyR { AssertRelationshipInJsonApiRequestIsNotNull(_request.Relationship); - HashSet rightResourceIds = _collectionConverter.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); + HashSet rightResourceIds = CollectionConverter.Instance.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); object? newRightValue = rightValue; - if (rightResourceIds.Any()) + if (rightResourceIds.Count > 0) { QueryLayer queryLayer = _queryLayerComposer.ComposeForGetRelationshipRightIds(_request.Relationship, rightResourceIds); @@ -426,9 +435,9 @@ private async Task GetForHasManyUpdateAsync(HasManyAttribute hasManyR // Now that we've fetched them, update the request types so that resource definitions observe the actually stored types. newRightValue = _request.Relationship is HasOneAttribute ? rightResourceIds.FirstOrDefault() - : _collectionConverter.CopyToTypedCollection(rightResourceIds, _request.Relationship.Property.PropertyType); + : CollectionConverter.Instance.CopyToTypedCollection(rightResourceIds, _request.Relationship.Property.PropertyType); - if (missingResources.Any()) + if (missingResources.Count > 0) { throw new ResourcesInRelationshipsNotFoundException(missingResources); } @@ -438,7 +447,7 @@ private async Task GetForHasManyUpdateAsync(HasManyAttribute hasManyR } /// - public virtual async Task UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken) + public virtual async Task UpdateAsync([DisallowNull] TId id, TResource resource, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -446,7 +455,7 @@ private async Task GetForHasManyUpdateAsync(HasManyAttribute hasManyR resource }); - ArgumentGuard.NotNull(resource, nameof(resource)); + ArgumentNullException.ThrowIfNull(resource); using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Update resource"); @@ -481,7 +490,7 @@ private async Task GetForHasManyUpdateAsync(HasManyAttribute hasManyR } /// - public virtual async Task SetRelationshipAsync(TId leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken) + public virtual async Task SetRelationshipAsync([DisallowNull] TId leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { @@ -490,12 +499,11 @@ public virtual async Task SetRelationshipAsync(TId leftId, string relationshipNa rightValue }); - ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); + ArgumentNullException.ThrowIfNull(relationshipName); + AssertHasRelationship(_request.Relationship, relationshipName); using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Set relationship"); - AssertHasRelationship(_request.Relationship, relationshipName); - object? effectiveRightValue = _request.Relationship.RightType.IsPartOfTypeHierarchy() // Some of the incoming right-side resources may be stored as a derived type. We fetch them, so we'll know // the stored types, which enables to invoke resource definitions with the stored right-side resources types. @@ -519,17 +527,17 @@ public virtual async Task SetRelationshipAsync(TId leftId, string relationshipNa } /// - public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToken) + public virtual async Task DeleteAsync([DisallowNull] TId id, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new { id }); - using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Delete resource"); - AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Delete resource"); + TResource? resourceFromDatabase = null; if (_request.PrimaryResourceType.IsPartOfTypeHierarchy()) @@ -552,7 +560,7 @@ public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToke } /// - public virtual async Task RemoveFromToManyRelationshipAsync(TId leftId, string relationshipName, ISet rightResourceIds, + public virtual async Task RemoveFromToManyRelationshipAsync([DisallowNull] TId leftId, string relationshipName, ISet rightResourceIds, CancellationToken cancellationToken) { _traceWriter.LogMethodStart(new @@ -562,12 +570,12 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TId leftId, string r rightResourceIds }); - ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); - ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); + ArgumentNullException.ThrowIfNull(relationshipName); + ArgumentNullException.ThrowIfNull(rightResourceIds); + AssertHasRelationship(_request.Relationship, relationshipName); using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Remove from to-many relationship"); - AssertHasRelationship(_request.Relationship, relationshipName); var hasManyRelationship = (HasManyAttribute)_request.Relationship; TResource resourceFromDatabase = await GetForHasManyUpdateAsync(hasManyRelationship, leftId, rightResourceIds, cancellationToken); @@ -581,7 +589,7 @@ public virtual async Task RemoveFromToManyRelationshipAsync(TId leftId, string r await _repositoryAccessor.RemoveFromToManyRelationshipAsync(resourceFromDatabase, effectiveRightResourceIds, cancellationToken); } - protected async Task GetPrimaryResourceByIdAsync(TId id, TopFieldSelection fieldSelection, CancellationToken cancellationToken) + protected async Task GetPrimaryResourceByIdAsync([DisallowNull] TId id, TopFieldSelection fieldSelection, CancellationToken cancellationToken) { TResource? primaryResource = await GetPrimaryResourceByIdOrDefaultAsync(id, fieldSelection, cancellationToken); AssertPrimaryResourceExists(primaryResource); @@ -589,17 +597,19 @@ protected async Task GetPrimaryResourceByIdAsync(TId id, TopFieldSele return primaryResource; } - private async Task GetPrimaryResourceByIdOrDefaultAsync(TId id, TopFieldSelection fieldSelection, CancellationToken cancellationToken) + private async Task GetPrimaryResourceByIdOrDefaultAsync([DisallowNull] TId id, TopFieldSelection fieldSelection, + CancellationToken cancellationToken) { - AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + // Using the non-accurized resource type, so that includes on sibling derived types can be used at abstract endpoint. + ResourceType resourceType = _repositoryAccessor.LookupResourceType(typeof(TResource)); - QueryLayer primaryLayer = _queryLayerComposer.ComposeForGetById(id, _request.PrimaryResourceType, fieldSelection); + QueryLayer primaryLayer = _queryLayerComposer.ComposeForGetById(id, resourceType, fieldSelection); IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync(primaryLayer, cancellationToken); return primaryResources.SingleOrDefault(); } - protected async Task GetPrimaryResourceForUpdateAsync(TId id, CancellationToken cancellationToken) + protected async Task GetPrimaryResourceForUpdateAsync([DisallowNull] TId id, CancellationToken cancellationToken) { AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); diff --git a/test/AnnotationTests/AnnotationTests.csproj b/test/AnnotationTests/AnnotationTests.csproj new file mode 100644 index 0000000000..885e9f769c --- /dev/null +++ b/test/AnnotationTests/AnnotationTests.csproj @@ -0,0 +1,11 @@ + + + net9.0;net8.0;netstandard2.0 + + + + + + + + diff --git a/test/AnnotationTests/Models/HiddenNode.cs b/test/AnnotationTests/Models/HiddenNode.cs new file mode 100644 index 0000000000..c348f3b5b0 --- /dev/null +++ b/test/AnnotationTests/Models/HiddenNode.cs @@ -0,0 +1,14 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace AnnotationTests.Models; + +[PublicAPI] +[NoResource] +[ResourceLinks(TopLevelLinks = LinkTypes.None, ResourceLinks = LinkTypes.None, RelationshipLinks = LinkTypes.None)] +public sealed class HiddenNode : Identifiable +{ + [EagerLoad] + public HiddenNode? Parent { get; set; } +} diff --git a/test/AnnotationTests/Models/TreeNode.cs b/test/AnnotationTests/Models/TreeNode.cs new file mode 100644 index 0000000000..269758fef6 --- /dev/null +++ b/test/AnnotationTests/Models/TreeNode.cs @@ -0,0 +1,22 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace AnnotationTests.Models; + +[PublicAPI] +[Resource(PublicName = "tree-node", ClientIdGeneration = ClientIdGenerationMode.Required, ControllerNamespace = "Models", + GenerateControllerEndpoints = JsonApiEndpoints.Query)] +public sealed class TreeNode : Identifiable +{ + [Attr(PublicName = "name", Capabilities = AttrCapabilities.AllowSort)] + public string? DisplayName { get; set; } + + [HasOne(PublicName = "orders", Capabilities = HasOneCapabilities.AllowView | HasOneCapabilities.AllowInclude, Links = LinkTypes.All)] + public TreeNode? Parent { get; set; } + + [HasMany(PublicName = "orders", Capabilities = HasManyCapabilities.AllowView | HasManyCapabilities.AllowFilter, Links = LinkTypes.All)] + public ISet Children { get; set; } = new HashSet(); +} diff --git a/test/DapperTests/DapperTests.csproj b/test/DapperTests/DapperTests.csproj new file mode 100644 index 0000000000..7d41d78911 --- /dev/null +++ b/test/DapperTests/DapperTests.csproj @@ -0,0 +1,19 @@ + + + net9.0;net8.0 + + + + + + + + + + + + + + + + diff --git a/test/DapperTests/IntegrationTests/AtomicOperations/AtomicOperationsTests.cs b/test/DapperTests/IntegrationTests/AtomicOperations/AtomicOperationsTests.cs new file mode 100644 index 0000000000..64acaaa57f --- /dev/null +++ b/test/DapperTests/IntegrationTests/AtomicOperations/AtomicOperationsTests.cs @@ -0,0 +1,553 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.AtomicOperations; + +public sealed class AtomicOperationsTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public AtomicOperationsTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_use_multiple_operations() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person newOwner = _fakers.Person.GenerateOne(); + Person newAssignee = _fakers.Person.GenerateOne(); + Tag newTag = _fakers.Tag.GenerateOne(); + TodoItem newTodoItem = _fakers.TodoItem.GenerateOne(); + + const string ownerLocalId = "new-owner"; + const string assigneeLocalId = "new-assignee"; + const string tagLocalId = "new-tag"; + const string todoItemLocalId = "new-todoItem"; + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "people", + lid = ownerLocalId, + attributes = new + { + firstName = newOwner.FirstName, + lastName = newOwner.LastName + } + } + }, + new + { + op = "add", + data = new + { + type = "people", + lid = assigneeLocalId, + attributes = new + { + firstName = newAssignee.FirstName, + lastName = newAssignee.LastName + } + } + }, + new + { + op = "add", + data = new + { + type = "tags", + lid = tagLocalId, + attributes = new + { + name = newTag.Name + } + } + }, + new + { + op = "add", + data = new + { + type = "todoItems", + lid = todoItemLocalId, + attributes = new + { + description = newTodoItem.Description, + priority = newTodoItem.Priority, + durationInHours = newTodoItem.DurationInHours + }, + relationships = new + { + owner = new + { + data = new + { + type = "people", + lid = ownerLocalId + } + } + } + } + }, + new + { + op = "update", + @ref = new + { + type = "todoItems", + lid = todoItemLocalId, + relationship = "assignee" + }, + data = new + { + type = "people", + lid = assigneeLocalId + } + }, + new + { + op = "update", + data = new + { + type = "todoItems", + lid = todoItemLocalId, + relationships = new + { + tags = new + { + data = new[] + { + new + { + type = "tags", + lid = tagLocalId + } + } + } + } + } + }, + new + { + op = "remove", + @ref = new + { + type = "people", + lid = assigneeLocalId + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(7); + + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.Type.Should().Be("people"); + responseDocument.Results[1].Data.SingleValue.RefShould().NotBeNull().And.Subject.Type.Should().Be("people"); + responseDocument.Results[2].Data.SingleValue.RefShould().NotBeNull().And.Subject.Type.Should().Be("tags"); + responseDocument.Results[3].Data.SingleValue.RefShould().NotBeNull().And.Subject.Type.Should().Be("todoItems"); + responseDocument.Results[4].Data.Value.Should().BeNull(); + responseDocument.Results[5].Data.SingleValue.RefShould().NotBeNull().And.Subject.Type.Should().Be("todoItems"); + responseDocument.Results[6].Data.Value.Should().BeNull(); + + long newOwnerId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); + long newAssigneeId = long.Parse(responseDocument.Results[1].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); + long newTagId = long.Parse(responseDocument.Results[2].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); + long newTodoItemId = long.Parse(responseDocument.Results[3].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + TodoItem todoItemInDatabase = await dbContext.TodoItems + .Include(todoItem => todoItem.Owner) + .Include(todoItem => todoItem.Assignee) + .Include(todoItem => todoItem.Tags) + .FirstWithIdAsync(newTodoItemId); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + todoItemInDatabase.Description.Should().Be(newTodoItem.Description); + todoItemInDatabase.Priority.Should().Be(newTodoItem.Priority); + todoItemInDatabase.DurationInHours.Should().Be(newTodoItem.DurationInHours); + todoItemInDatabase.CreatedAt.Should().Be(DapperTestContext.FrozenTime); + todoItemInDatabase.LastModifiedAt.Should().Be(DapperTestContext.FrozenTime); + + todoItemInDatabase.Owner.Should().NotBeNull(); + todoItemInDatabase.Owner.Id.Should().Be(newOwnerId); + todoItemInDatabase.Assignee.Should().BeNull(); + todoItemInDatabase.Tags.Should().HaveCount(1); + todoItemInDatabase.Tags.ElementAt(0).Id.Should().Be(newTagId); + }); + + store.SqlCommands.Should().HaveCount(15); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + INSERT INTO "People" ("FirstName", "LastName", "AccountId") + VALUES (@p1, @p2, @p3) + RETURNING "Id" + """)); + + command.Parameters.Should().HaveCount(3); + command.Parameters.Should().Contain("@p1", newOwner.FirstName); + command.Parameters.Should().Contain("@p2", newOwner.LastName); + command.Parameters.Should().Contain("@p3", null); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName" + FROM "People" AS t1 + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", newOwnerId); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + INSERT INTO "People" ("FirstName", "LastName", "AccountId") + VALUES (@p1, @p2, @p3) + RETURNING "Id" + """)); + + command.Parameters.Should().HaveCount(3); + command.Parameters.Should().Contain("@p1", newAssignee.FirstName); + command.Parameters.Should().Contain("@p2", newAssignee.LastName); + command.Parameters.Should().Contain("@p3", null); + }); + + store.SqlCommands[3].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName" + FROM "People" AS t1 + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", newAssigneeId); + }); + + store.SqlCommands[4].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + INSERT INTO "Tags" ("Name", "TodoItemId") + VALUES (@p1, @p2) + RETURNING "Id" + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", newTag.Name); + command.Parameters.Should().Contain("@p2", null); + }); + + store.SqlCommands[5].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."Name" + FROM "Tags" AS t1 + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", newTagId); + }); + + store.SqlCommands[6].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + INSERT INTO "TodoItems" ("Description", "Priority", "DurationInHours", "CreatedAt", "LastModifiedAt", "OwnerId", "AssigneeId") + VALUES (@p1, @p2, @p3, @p4, @p5, @p6, @p7) + RETURNING "Id" + """)); + + command.Parameters.Should().HaveCount(7); + command.Parameters.Should().Contain("@p1", newTodoItem.Description); + command.Parameters.Should().Contain("@p2", newTodoItem.Priority); + command.Parameters.Should().Contain("@p3", newTodoItem.DurationInHours); + command.Parameters.Should().Contain("@p4", DapperTestContext.FrozenTime); + command.Parameters.Should().Contain("@p5", null); + command.Parameters.Should().Contain("@p6", newOwnerId); + command.Parameters.Should().Contain("@p7", null); + }); + + store.SqlCommands[7].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", newTodoItemId); + }); + + store.SqlCommands[8].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority", t2."Id", t2."FirstName", t2."LastName" + FROM "TodoItems" AS t1 + LEFT JOIN "People" AS t2 ON t1."AssigneeId" = t2."Id" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", newTodoItemId); + }); + + store.SqlCommands[9].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "TodoItems" + SET "AssigneeId" = @p1 + WHERE "Id" = @p2 + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", newAssigneeId); + command.Parameters.Should().Contain("@p2", newTodoItemId); + }); + + store.SqlCommands[10].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority", t2."Id", t2."Name" + FROM "TodoItems" AS t1 + LEFT JOIN "Tags" AS t2 ON t1."Id" = t2."TodoItemId" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", newTodoItemId); + }); + + store.SqlCommands[11].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "TodoItems" + SET "LastModifiedAt" = @p1 + WHERE "Id" = @p2 + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", DapperTestContext.FrozenTime); + command.Parameters.Should().Contain("@p2", newTodoItemId); + }); + + store.SqlCommands[12].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "Tags" + SET "TodoItemId" = @p1 + WHERE "Id" = @p2 + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", newTodoItemId); + command.Parameters.Should().Contain("@p2", newTagId); + }); + + store.SqlCommands[13].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", newTodoItemId); + }); + + store.SqlCommands[14].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + DELETE FROM "People" + WHERE "Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", newAssigneeId); + }); + } + + [Fact] + public async Task Can_rollback_on_error() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person newPerson = _fakers.Person.GenerateOne(); + + const long unknownTodoItemId = Unknown.TypedId.Int64; + + const string personLocalId = "new-person"; + + await _testContext.RunOnDatabaseAsync(_testContext.ClearAllTablesAsync); + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "people", + lid = personLocalId, + attributes = new + { + lastName = newPerson.LastName + } + } + }, + new + { + op = "update", + @ref = new + { + type = "people", + lid = personLocalId, + relationship = "assignedTodoItems" + }, + data = new[] + { + new + { + type = "todoItems", + id = unknownTodoItemId.ToString() + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("A related resource does not exist."); + error.Detail.Should().Be($"Related resource of type 'todoItems' with ID '{unknownTodoItemId}' in relationship 'assignedTodoItems' does not exist."); + error.Source.Should().NotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[1]"); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + List peopleInDatabase = await dbContext.People.ToListAsync(); + peopleInDatabase.Should().BeEmpty(); + }); + + store.SqlCommands.Should().HaveCount(5); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + INSERT INTO "People" ("FirstName", "LastName", "AccountId") + VALUES (@p1, @p2, @p3) + RETURNING "Id" + """)); + + command.Parameters.Should().HaveCount(3); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", newPerson.LastName); + command.Parameters.Should().Contain("@p3", null); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName" + FROM "People" AS t1 + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().ContainKey("@p1").WhoseValue.Should().NotBeNull(); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."Priority" + FROM "People" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."Id" = t2."AssigneeId" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().ContainKey("@p1").WhoseValue.Should().NotBeNull(); + }); + + store.SqlCommands[3].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "TodoItems" + SET "AssigneeId" = @p1 + WHERE "Id" = @p2 + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().ContainKey("@p1").WhoseValue.Should().NotBeNull(); + command.Parameters.Should().Contain("@p2", unknownTodoItemId); + }); + + store.SqlCommands[4].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id" + FROM "TodoItems" AS t1 + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", unknownTodoItemId); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/DapperTestContext.cs b/test/DapperTests/IntegrationTests/DapperTestContext.cs new file mode 100644 index 0000000000..2156902f98 --- /dev/null +++ b/test/DapperTests/IntegrationTests/DapperTestContext.cs @@ -0,0 +1,169 @@ +using System.Text.Json; +using DapperExample; +using DapperExample.Data; +using DapperExample.Models; +using DapperExample.Repositories; +using DapperExample.TranslationToSql.DataModel; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using TestBuildingBlocks; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests; + +[PublicAPI] +public sealed class DapperTestContext : IntegrationTest +{ + private const string SqlServerClearAllTablesScript = """ + EXEC sp_MSForEachTable 'ALTER TABLE ? NOCHECK CONSTRAINT ALL'; + EXEC sp_MSForEachTable 'SET QUOTED_IDENTIFIER ON; DELETE FROM ?'; + EXEC sp_MSForEachTable 'ALTER TABLE ? CHECK CONSTRAINT ALL'; + """; + + public static readonly DateTimeOffset FrozenTime = DefaultDateTimeUtc; + + private readonly Lazy> _lazyFactory; + private ITestOutputHelper? _testOutputHelper; + + protected override JsonSerializerOptions SerializerOptions + { + get + { + var options = Factory.Services.GetRequiredService(); + return options.SerializerOptions; + } + } + + public WebApplicationFactory Factory => _lazyFactory.Value; + + public DapperTestContext() + { + _lazyFactory = new Lazy>(CreateFactory); + } + + private WebApplicationFactory CreateFactory() + { +#pragma warning disable CA2000 // Dispose objects before losing scope + // Justification: The child factory returned by WithWebHostBuilder() is owned by the parent factory, which disposes it. + return new WebApplicationFactory().WithWebHostBuilder(builder => +#pragma warning restore CA2000 // Dispose objects before losing scope + { + builder.UseSetting("ConnectionStrings:DapperExamplePostgreSql", + $"Host=localhost;Database=DapperExample-{Guid.NewGuid():N};User ID=postgres;Password=postgres;Include Error Detail=true"); + + builder.UseSetting("ConnectionStrings:DapperExampleMySql", + $"Host=localhost;Database=DapperExample-{Guid.NewGuid():N};User ID=root;Password=mysql;SSL Mode=None;AllowPublicKeyRetrieval=True"); + + builder.UseSetting("ConnectionStrings:DapperExampleSqlServer", + $"Server=localhost;Database=DapperExample-{Guid.NewGuid():N};User ID=sa;Password=Passw0rd!;TrustServerCertificate=true"); + + builder.UseSetting("Logging:LogLevel:DapperExample", "Debug"); + + builder.ConfigureLogging(loggingBuilder => + { + if (_testOutputHelper != null) + { +#if !DEBUG + // Reduce logging output when running tests in ci-build. + loggingBuilder.ClearProviders(); +#endif + loggingBuilder.Services.AddSingleton(_ => new XUnitLoggerProvider(_testOutputHelper, "DapperExample.")); + } + }); + + builder.ConfigureServices(services => + { + services.Replace(ServiceDescriptor.Singleton(new FrozenTimeProvider(FrozenTime))); + + ServiceDescriptor scopedCaptureStore = services.Single(descriptor => descriptor.ImplementationType == typeof(SqlCaptureStore)); + services.Remove(scopedCaptureStore); + + services.AddSingleton(); + }); + }); + } + + public void SetTestOutputHelper(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } + + public async Task ClearAllTablesAsync(DbContext dbContext) + { + var dataModelService = Factory.Services.GetRequiredService(); + DatabaseProvider databaseProvider = dataModelService.DatabaseProvider; + + if (databaseProvider == DatabaseProvider.SqlServer) + { + await dbContext.Database.ExecuteSqlRawAsync(SqlServerClearAllTablesScript); + } + else + { + foreach (IEntityType entityType in dbContext.Model.GetEntityTypes()) + { + string? tableName = entityType.GetTableName(); + + string escapedTableName = databaseProvider switch + { + DatabaseProvider.PostgreSql => $"\"{tableName}\"", + DatabaseProvider.MySql => $"`{tableName}`", + _ => throw new NotSupportedException($"Unsupported database provider '{databaseProvider}'.") + }; + +#pragma warning disable EF1002 // Risk of vulnerability to SQL injection. + // Justification: Table names cannot be parameterized. + await dbContext.Database.ExecuteSqlRawAsync($"DELETE FROM {escapedTableName}"); +#pragma warning restore EF1002 // Risk of vulnerability to SQL injection. + } + } + } + + public async Task RunOnDatabaseAsync(Func asyncAction) + { + await using AsyncServiceScope scope = Factory.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + await asyncAction(dbContext); + } + + public string AdaptSql(string text, bool hasClientGeneratedId = false) + { + var dataModelService = Factory.Services.GetRequiredService(); + var adapter = new SqlTextAdapter(dataModelService.DatabaseProvider); + return adapter.Adapt(text, hasClientGeneratedId); + } + + protected override HttpClient CreateClient() + { + return Factory.CreateClient(); + } + + public override async Task DisposeAsync() + { + try + { + if (_lazyFactory.IsValueCreated) + { + try + { + await RunOnDatabaseAsync(async dbContext => await dbContext.Database.EnsureDeletedAsync()); + } + finally + { + await _lazyFactory.Value.DisposeAsync(); + } + } + } + finally + { + await base.DisposeAsync(); + } + } +} diff --git a/test/DapperTests/IntegrationTests/QueryStrings/FilterTests.cs b/test/DapperTests/IntegrationTests/QueryStrings/FilterTests.cs new file mode 100644 index 0000000000..c303d70341 --- /dev/null +++ b/test/DapperTests/IntegrationTests/QueryStrings/FilterTests.cs @@ -0,0 +1,1298 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.QueryStrings; + +public sealed class FilterTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public FilterTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_filter_equals_on_obfuscated_id_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List tags = _fakers.Tag.GenerateList(3); + tags.ForEach(tag => tag.Color = _fakers.RgbColor.GenerateOne()); + + tags[0].Color!.StringId = "FF0000"; + tags[1].Color!.StringId = "00FF00"; + tags[2].Color!.StringId = "0000FF"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.Tags.AddRange(tags); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/tags?filter=equals(color.id,'00FF00')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("tags"); + responseDocument.Data.ManyValue[0].Id.Should().Be(tags[1].StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "Tags" AS t1 + LEFT JOIN "RgbColors" AS t2 ON t1."Id" = t2."TagId" + WHERE t2."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", 0x00FF00); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."Name" + FROM "Tags" AS t1 + LEFT JOIN "RgbColors" AS t2 ON t1."Id" = t2."TagId" + WHERE t2."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", 0x00FF00); + }); + } + + [Fact] + public async Task Can_filter_any_on_obfuscated_id_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List tags = _fakers.Tag.GenerateList(3); + tags.ForEach(tag => tag.Color = _fakers.RgbColor.GenerateOne()); + + tags[0].Color!.StringId = "FF0000"; + tags[1].Color!.StringId = "00FF00"; + tags[2].Color!.StringId = "0000FF"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.Tags.AddRange(tags); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/tags?filter=any(color.id,'00FF00','11EE11')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("tags"); + responseDocument.Data.ManyValue[0].Id.Should().Be(tags[1].StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "Tags" AS t1 + LEFT JOIN "RgbColors" AS t2 ON t1."Id" = t2."TagId" + WHERE t2."Id" IN (@p1, @p2) + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", 0x00FF00); + command.Parameters.Should().Contain("@p2", 0x11EE11); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."Name" + FROM "Tags" AS t1 + LEFT JOIN "RgbColors" AS t2 ON t1."Id" = t2."TagId" + WHERE t2."Id" IN (@p1, @p2) + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", 0x00FF00); + command.Parameters.Should().Contain("@p2", 0x11EE11); + }); + } + + [Fact] + public async Task Can_filter_equals_null_on_relationship_at_secondary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.GenerateOne(); + person.OwnedTodoItems = _fakers.TodoItem.GenerateSet(2); + person.OwnedTodoItems.ElementAt(0).Assignee = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/people/{person.StringId}/ownedTodoItems?filter=equals(assignee,null)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(person.OwnedTodoItems.ElementAt(1).StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + INNER JOIN "People" AS t2 ON t1."OwnerId" = t2."Id" + LEFT JOIN "People" AS t3 ON t1."AssigneeId" = t3."Id" + WHERE (t2."Id" = @p1) AND (t3."Id" IS NULL) + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t4."Id", t4."CreatedAt", t4."Description", t4."DurationInHours", t4."LastModifiedAt", t4."Priority" + FROM "People" AS t1 + LEFT JOIN ( + SELECT t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."OwnerId", t2."Priority" + FROM "TodoItems" AS t2 + LEFT JOIN "People" AS t3 ON t2."AssigneeId" = t3."Id" + WHERE t3."Id" IS NULL + ) AS t4 ON t1."Id" = t4."OwnerId" + WHERE t1."Id" = @p1 + ORDER BY t4."Priority", t4."LastModifiedAt" DESC + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + } + + [Fact] + public async Task Can_filter_equals_null_on_attribute_at_secondary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.GenerateOne(); + person.OwnedTodoItems = _fakers.TodoItem.GenerateSet(2); + person.OwnedTodoItems.ElementAt(1).DurationInHours = null; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/people/{person.StringId}/ownedTodoItems?filter=equals(durationInHours,null)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(person.OwnedTodoItems.ElementAt(1).StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + INNER JOIN "People" AS t2 ON t1."OwnerId" = t2."Id" + WHERE (t2."Id" = @p1) AND (t1."DurationInHours" IS NULL) + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t3."Id", t3."CreatedAt", t3."Description", t3."DurationInHours", t3."LastModifiedAt", t3."Priority" + FROM "People" AS t1 + LEFT JOIN ( + SELECT t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."OwnerId", t2."Priority" + FROM "TodoItems" AS t2 + WHERE t2."DurationInHours" IS NULL + ) AS t3 ON t1."Id" = t3."OwnerId" + WHERE t1."Id" = @p1 + ORDER BY t3."Priority", t3."LastModifiedAt" DESC + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + } + + [Fact] + public async Task Can_filter_equals_on_enum_attribute_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.GenerateList(3); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.GenerateOne()); + todoItems.ForEach(todoItem => todoItem.Priority = TodoItemPriority.Low); + + todoItems[1].Priority = TodoItemPriority.Medium; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?filter=equals(priority,'Medium')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItems[1].StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + WHERE t1."Priority" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", todoItems[1].Priority); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + WHERE t1."Priority" = @p1 + ORDER BY t1."Priority", t1."LastModifiedAt" DESC + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", todoItems[1].Priority); + }); + } + + [Fact] + public async Task Can_filter_equals_on_string_attribute_at_secondary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.GenerateOne(); + person.AssignedTodoItems = _fakers.TodoItem.GenerateSet(2); + person.AssignedTodoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.GenerateOne()); + + person.AssignedTodoItems.ElementAt(1).Description = "Take exam"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/people/{person.StringId}/assignedTodoItems?filter=equals(description,'Take exam')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(person.AssignedTodoItems.ElementAt(1).StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + LEFT JOIN "People" AS t2 ON t1."AssigneeId" = t2."Id" + WHERE (t2."Id" = @p1) AND (t1."Description" = @p2) + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", person.Id); + command.Parameters.Should().Contain("@p2", person.AssignedTodoItems.ElementAt(1).Description); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t3."Id", t3."CreatedAt", t3."Description", t3."DurationInHours", t3."LastModifiedAt", t3."Priority" + FROM "People" AS t1 + LEFT JOIN ( + SELECT t2."Id", t2."AssigneeId", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."Priority" + FROM "TodoItems" AS t2 + WHERE t2."Description" = @p2 + ) AS t3 ON t1."Id" = t3."AssigneeId" + WHERE t1."Id" = @p1 + ORDER BY t3."Priority", t3."LastModifiedAt" DESC + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", person.Id); + command.Parameters.Should().Contain("@p2", person.AssignedTodoItems.ElementAt(1).Description); + }); + } + + [Fact] + public async Task Can_filter_equality_on_attributes_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.GenerateList(2); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.GenerateOne()); + todoItems.ForEach(todoItem => todoItem.Assignee = _fakers.Person.GenerateOne()); + + todoItems[1].Assignee!.FirstName = todoItems[1].Assignee!.LastName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?filter=equals(assignee.lastName,assignee.firstName)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItems[1].StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + LEFT JOIN "People" AS t2 ON t1."AssigneeId" = t2."Id" + WHERE t2."LastName" = t2."FirstName" + """)); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + LEFT JOIN "People" AS t2 ON t1."AssigneeId" = t2."Id" + WHERE t2."LastName" = t2."FirstName" + ORDER BY t1."Priority", t1."LastModifiedAt" DESC + """)); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_filter_any_with_single_constant_at_secondary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.GenerateOne(); + person.OwnedTodoItems = _fakers.TodoItem.GenerateSet(2); + + person.OwnedTodoItems.ElementAt(0).Priority = TodoItemPriority.Low; + person.OwnedTodoItems.ElementAt(1).Priority = TodoItemPriority.Medium; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/people/{person.StringId}/ownedTodoItems?filter=any(priority,'Medium')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(person.OwnedTodoItems.ElementAt(1).StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + INNER JOIN "People" AS t2 ON t1."OwnerId" = t2."Id" + WHERE (t2."Id" = @p1) AND (t1."Priority" = @p2) + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", person.Id); + command.Parameters.Should().Contain("@p2", TodoItemPriority.Medium); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t3."Id", t3."CreatedAt", t3."Description", t3."DurationInHours", t3."LastModifiedAt", t3."Priority" + FROM "People" AS t1 + LEFT JOIN ( + SELECT t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."OwnerId", t2."Priority" + FROM "TodoItems" AS t2 + WHERE t2."Priority" = @p2 + ) AS t3 ON t1."Id" = t3."OwnerId" + WHERE t1."Id" = @p1 + ORDER BY t3."Priority", t3."LastModifiedAt" DESC + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", person.Id); + command.Parameters.Should().Contain("@p2", TodoItemPriority.Medium); + }); + } + + [Fact] + public async Task Can_filter_not_not_not_not_equals_on_string_attribute_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.GenerateOne(); + todoItem.Description = "X"; + todoItem.Owner = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?filter=not(not(not(not(equals(description,'X')))))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItem.StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + WHERE t1."Description" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", "X"); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + WHERE t1."Description" = @p1 + ORDER BY t1."Priority", t1."LastModifiedAt" DESC + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", "X"); + }); + } + + [Fact] + public async Task Can_filter_not_equals_on_nullable_attribute_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List people = _fakers.Person.GenerateList(3); + people[0].FirstName = "X"; + people[1].FirstName = null; + people[2].FirstName = "Y"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.AddRange(people); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?filter=not(equals(firstName,'X'))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("people")); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == people[1].StringId); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == people[2].StringId); + + responseDocument.Meta.Should().ContainTotal(2); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "People" AS t1 + WHERE (NOT (t1."FirstName" = @p1)) OR (t1."FirstName" IS NULL) + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", "X"); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName" + FROM "People" AS t1 + WHERE (NOT (t1."FirstName" = @p1)) OR (t1."FirstName" IS NULL) + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", "X"); + }); + } + + [Fact] + public async Task Can_filter_not_equals_on_attributes_of_optional_relationship_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.GenerateList(2); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.GenerateOne()); + + todoItems[1].Assignee = _fakers.Person.GenerateOne(); + todoItems[1].Assignee!.FirstName = "X"; + todoItems[1].Assignee!.LastName = "Y"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?filter=not(and(equals(assignee.firstName,'X'),equals(assignee.lastName,'Y')))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItems[0].StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + LEFT JOIN "People" AS t2 ON t1."AssigneeId" = t2."Id" + WHERE (NOT ((t2."FirstName" = @p1) AND (t2."LastName" = @p2))) OR (t2."FirstName" IS NULL) OR (t2."LastName" IS NULL) + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", "X"); + command.Parameters.Should().Contain("@p2", "Y"); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + LEFT JOIN "People" AS t2 ON t1."AssigneeId" = t2."Id" + WHERE (NOT ((t2."FirstName" = @p1) AND (t2."LastName" = @p2))) OR (t2."FirstName" IS NULL) OR (t2."LastName" IS NULL) + ORDER BY t1."Priority", t1."LastModifiedAt" DESC + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", "X"); + command.Parameters.Should().Contain("@p2", "Y"); + }); + } + + [Fact] + public async Task Can_filter_text_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.GenerateList(3); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.GenerateOne()); + + todoItems[0].Description = "One"; + todoItems[1].Description = "Two"; + todoItems[1].Owner.FirstName = "Jack"; + todoItems[2].Description = "Three"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = + "/todoItems?filter=and(startsWith(description,'T'),not(any(description,'Three','Four')),equals(owner.firstName,'Jack'),contains(description,'o'))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItems[1].StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + INNER JOIN "People" AS t2 ON t1."OwnerId" = t2."Id" + WHERE (t1."Description" LIKE 'T%') AND (NOT (t1."Description" IN (@p1, @p2))) AND (t2."FirstName" = @p3) AND (t1."Description" LIKE '%o%') + """)); + + command.Parameters.Should().HaveCount(3); + command.Parameters.Should().Contain("@p1", "Four"); + command.Parameters.Should().Contain("@p2", "Three"); + command.Parameters.Should().Contain("@p3", "Jack"); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + INNER JOIN "People" AS t2 ON t1."OwnerId" = t2."Id" + WHERE (t1."Description" LIKE 'T%') AND (NOT (t1."Description" IN (@p1, @p2))) AND (t2."FirstName" = @p3) AND (t1."Description" LIKE '%o%') + ORDER BY t1."Priority", t1."LastModifiedAt" DESC + """)); + + command.Parameters.Should().HaveCount(3); + command.Parameters.Should().Contain("@p1", "Four"); + command.Parameters.Should().Contain("@p2", "Three"); + command.Parameters.Should().Contain("@p3", "Jack"); + }); + } + + [Fact] + public async Task Can_filter_special_characters_in_text_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List tags = _fakers.Tag.GenerateList(6); + tags[0].Name = "A%Z"; + tags[1].Name = "A_Z"; + tags[2].Name = @"A\Z"; + tags[3].Name = "A'Z"; + tags[4].Name = @"A%_\'Z"; + tags[5].Name = "AZ"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.Tags.AddRange(tags); + await dbContext.SaveChangesAsync(); + }); + + const string route = @"/tags?filter=or(contains(name,'A%'),contains(name,'A_'),contains(name,'A\'),contains(name,'A'''),contains(name,'%_\'''))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(5); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("tags")); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == tags[0].StringId); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == tags[1].StringId); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == tags[2].StringId); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == tags[3].StringId); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == tags[4].StringId); + + responseDocument.Meta.Should().ContainTotal(5); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "Tags" AS t1 + WHERE (t1."Name" LIKE '%A\%%' ESCAPE '\') OR (t1."Name" LIKE '%A\_%' ESCAPE '\') OR (t1."Name" LIKE '%A\\%' ESCAPE '\') OR (t1."Name" LIKE '%A''%') OR (t1."Name" LIKE '%\%\_\\''%' ESCAPE '\') + """)); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."Name" + FROM "Tags" AS t1 + WHERE (t1."Name" LIKE '%A\%%' ESCAPE '\') OR (t1."Name" LIKE '%A\_%' ESCAPE '\') OR (t1."Name" LIKE '%A\\%' ESCAPE '\') OR (t1."Name" LIKE '%A''%') OR (t1."Name" LIKE '%\%\_\\''%' ESCAPE '\') + """)); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_filter_numeric_range_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.GenerateList(3); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.GenerateOne()); + + todoItems[0].DurationInHours = 100; + todoItems[1].DurationInHours = 200; + todoItems[2].DurationInHours = 300; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?filter=or(greaterThan(durationInHours,'250'),lessOrEqual(durationInHours,'100'))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("todoItems")); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == todoItems[0].StringId); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == todoItems[2].StringId); + + responseDocument.Meta.Should().ContainTotal(2); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + WHERE (t1."DurationInHours" > @p1) OR (t1."DurationInHours" <= @p2) + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", 250); + command.Parameters.Should().Contain("@p2", 100); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + WHERE (t1."DurationInHours" > @p1) OR (t1."DurationInHours" <= @p2) + ORDER BY t1."Priority", t1."LastModifiedAt" DESC + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", 250); + command.Parameters.Should().Contain("@p2", 100); + }); + } + + [Fact] + public async Task Can_filter_count_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.GenerateList(2); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.GenerateOne()); + + todoItems[1].Owner.AssignedTodoItems = _fakers.TodoItem.GenerateSet(2); + todoItems[1].Owner.AssignedTodoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.GenerateOne()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?filter=and(greaterThan(count(owner.assignedTodoItems),'1'),not(equals(owner,null)))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItems[1].StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + INNER JOIN "People" AS t4 ON t1."OwnerId" = t4."Id" + WHERE (( + SELECT COUNT(*) + FROM "People" AS t2 + LEFT JOIN "TodoItems" AS t3 ON t2."Id" = t3."AssigneeId" + WHERE t1."OwnerId" = t2."Id" + ) > @p1) AND (NOT (t4."Id" IS NULL)) + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", 1); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + INNER JOIN "People" AS t4 ON t1."OwnerId" = t4."Id" + WHERE (( + SELECT COUNT(*) + FROM "People" AS t2 + LEFT JOIN "TodoItems" AS t3 ON t2."Id" = t3."AssigneeId" + WHERE t1."OwnerId" = t2."Id" + ) > @p1) AND (NOT (t4."Id" IS NULL)) + ORDER BY t1."Priority", t1."LastModifiedAt" DESC + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", 1); + }); + } + + [Fact] + public async Task Can_filter_nested_conditional_has_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.GenerateList(2); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.GenerateOne()); + + todoItems[1].Owner.AssignedTodoItems = _fakers.TodoItem.GenerateSet(2); + + todoItems[1].Owner.AssignedTodoItems.ForEach(todoItem => + { + todoItem.Description = "Homework"; + todoItem.Owner = _fakers.Person.GenerateOne(); + todoItem.Owner.LastName = "Smith"; + todoItem.Tags = _fakers.Tag.GenerateSet(1); + }); + + todoItems[1].Owner.AssignedTodoItems.ElementAt(1).Tags.ElementAt(0).Name = "Personal"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = + "/todoItems?filter=has(owner.assignedTodoItems,and(has(tags,equals(name,'Personal')),equals(owner.lastName,'Smith'),equals(description,'Homework')))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItems[1].StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + WHERE EXISTS ( + SELECT 1 + FROM "People" AS t2 + LEFT JOIN "TodoItems" AS t3 ON t2."Id" = t3."AssigneeId" + INNER JOIN "People" AS t5 ON t3."OwnerId" = t5."Id" + WHERE (t1."OwnerId" = t2."Id") AND (EXISTS ( + SELECT 1 + FROM "Tags" AS t4 + WHERE (t3."Id" = t4."TodoItemId") AND (t4."Name" = @p1) + )) AND (t5."LastName" = @p2) AND (t3."Description" = @p3) + ) + """)); + + command.Parameters.Should().HaveCount(3); + command.Parameters.Should().Contain("@p1", "Personal"); + command.Parameters.Should().Contain("@p2", "Smith"); + command.Parameters.Should().Contain("@p3", "Homework"); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + WHERE EXISTS ( + SELECT 1 + FROM "People" AS t2 + LEFT JOIN "TodoItems" AS t3 ON t2."Id" = t3."AssigneeId" + INNER JOIN "People" AS t5 ON t3."OwnerId" = t5."Id" + WHERE (t1."OwnerId" = t2."Id") AND (EXISTS ( + SELECT 1 + FROM "Tags" AS t4 + WHERE (t3."Id" = t4."TodoItemId") AND (t4."Name" = @p1) + )) AND (t5."LastName" = @p2) AND (t3."Description" = @p3) + ) + ORDER BY t1."Priority", t1."LastModifiedAt" DESC + """)); + + command.Parameters.Should().HaveCount(3); + command.Parameters.Should().Contain("@p1", "Personal"); + command.Parameters.Should().Contain("@p2", "Smith"); + command.Parameters.Should().Contain("@p3", "Homework"); + }); + } + + [Fact] + public async Task Can_filter_conditional_has_with_null_check_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List people = _fakers.Person.GenerateList(3); + people.ForEach(person => person.OwnedTodoItems = _fakers.TodoItem.GenerateSet(1)); + + people[0].OwnedTodoItems.ElementAt(0).Assignee = null; + + people[1].OwnedTodoItems.ElementAt(0).Assignee = _fakers.Person.GenerateOne(); + + people[2].OwnedTodoItems.ElementAt(0).Assignee = _fakers.Person.GenerateOne(); + people[2].OwnedTodoItems.ElementAt(0).Assignee!.FirstName = null; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.AddRange(people); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?filter=has(ownedTodoItems,and(not(equals(assignee,null)),equals(assignee.firstName,null)))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("people"); + responseDocument.Data.ManyValue[0].Id.Should().Be(people[2].StringId); + + responseDocument.Meta.Should().ContainTotal(1); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "People" AS t1 + WHERE EXISTS ( + SELECT 1 + FROM "TodoItems" AS t2 + LEFT JOIN "People" AS t3 ON t2."AssigneeId" = t3."Id" + WHERE (t1."Id" = t2."OwnerId") AND (NOT (t3."Id" IS NULL)) AND (t3."FirstName" IS NULL) + ) + """)); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName" + FROM "People" AS t1 + WHERE EXISTS ( + SELECT 1 + FROM "TodoItems" AS t2 + LEFT JOIN "People" AS t3 ON t2."AssigneeId" = t3."Id" + WHERE (t1."Id" = t2."OwnerId") AND (NOT (t3."Id" IS NULL)) AND (t3."FirstName" IS NULL) + ) + """)); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_filter_using_logical_operators_at_primary_endpoint() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.GenerateList(5); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.GenerateOne()); + + todoItems[0].Description = "0"; + todoItems[0].Priority = TodoItemPriority.High; + todoItems[0].DurationInHours = 1; + + todoItems[1].Description = "1"; + todoItems[1].Priority = TodoItemPriority.Low; + todoItems[1].DurationInHours = 0; + + todoItems[2].Description = "1"; + todoItems[2].Priority = TodoItemPriority.Low; + todoItems[2].DurationInHours = 1; + + todoItems[3].Description = "1"; + todoItems[3].Priority = TodoItemPriority.High; + todoItems[3].DurationInHours = 0; + + todoItems[4].Description = "1"; + todoItems[4].Priority = TodoItemPriority.High; + todoItems[4].DurationInHours = 1; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?filter=and(equals(description,'1'),or(equals(priority,'High'),equals(durationInHours,'1')))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(3); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("todoItems")); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == todoItems[2].StringId); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == todoItems[3].StringId); + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == todoItems[4].StringId); + + responseDocument.Meta.Should().ContainTotal(3); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + WHERE (t1."Description" = @p1) AND ((t1."Priority" = @p2) OR (t1."DurationInHours" = @p3)) + """)); + + command.Parameters.Should().HaveCount(3); + command.Parameters.Should().Contain("@p1", "1"); + command.Parameters.Should().Contain("@p2", TodoItemPriority.High); + command.Parameters.Should().Contain("@p3", 1); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + WHERE (t1."Description" = @p1) AND ((t1."Priority" = @p2) OR (t1."DurationInHours" = @p3)) + ORDER BY t1."Priority", t1."LastModifiedAt" DESC + """)); + + command.Parameters.Should().HaveCount(3); + command.Parameters.Should().Contain("@p1", "1"); + command.Parameters.Should().Contain("@p2", TodoItemPriority.High); + command.Parameters.Should().Contain("@p3", 1); + }); + } + + [Fact] + public async Task Cannot_filter_on_unmapped_attribute() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?filter=equals(displayName,'John Doe')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Sorting or filtering on the requested attribute is unavailable."); + error.Detail.Should().Be("Sorting or filtering on attribute 'displayName' is unavailable because it is unmapped."); + error.Source.Should().BeNull(); + } +} diff --git a/test/DapperTests/IntegrationTests/QueryStrings/IncludeTests.cs b/test/DapperTests/IntegrationTests/QueryStrings/IncludeTests.cs new file mode 100644 index 0000000000..84625b463f --- /dev/null +++ b/test/DapperTests/IntegrationTests/QueryStrings/IncludeTests.cs @@ -0,0 +1,240 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.QueryStrings; + +public sealed class IncludeTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public IncludeTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_get_primary_resources_with_multiple_include_chains() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person owner = _fakers.Person.GenerateOne(); + + List todoItems = _fakers.TodoItem.GenerateList(2); + todoItems.ForEach(todoItem => todoItem.Owner = owner); + todoItems.ForEach(todoItem => todoItem.Tags = _fakers.Tag.GenerateSet(2)); + todoItems[1].Assignee = _fakers.Person.GenerateOne(); + + todoItems[0].Priority = TodoItemPriority.High; + todoItems[1].Priority = TodoItemPriority.Low; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?include=owner.assignedTodoItems,assignee,tags"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("todoItems")); + + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItems[0].StringId); + + responseDocument.Data.ManyValue[0].Relationships.With(relationships => + { + relationships.Should().ContainKey("owner").WhoseValue.With(value => + { + value.Should().NotBeNull(); + value.Data.SingleValue.Should().NotBeNull(); + value.Data.SingleValue.Type.Should().Be("people"); + value.Data.SingleValue.Id.Should().Be(todoItems[0].Owner.StringId); + }); + + relationships.Should().ContainKey("assignee").WhoseValue.With(value => + { + value.Should().NotBeNull(); + value.Data.SingleValue.Should().BeNull(); + }); + + relationships.Should().ContainKey("tags").WhoseValue.With(value => + { + value.Should().NotBeNull(); + value.Data.ManyValue.Should().HaveCount(2); + value.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("tags")); + value.Data.ManyValue[0].Id.Should().Be(todoItems[0].Tags.ElementAt(0).StringId); + value.Data.ManyValue[1].Id.Should().Be(todoItems[0].Tags.ElementAt(1).StringId); + }); + }); + + responseDocument.Data.ManyValue[1].Id.Should().Be(todoItems[1].StringId); + + responseDocument.Data.ManyValue[1].Relationships.With(relationships => + { + relationships.Should().ContainKey("owner").WhoseValue.With(value => + { + value.Should().NotBeNull(); + value.Data.SingleValue.Should().NotBeNull(); + value.Data.SingleValue.Type.Should().Be("people"); + value.Data.SingleValue.Id.Should().Be(todoItems[1].Owner.StringId); + }); + + relationships.Should().ContainKey("assignee").WhoseValue.With(value => + { + value.Should().NotBeNull(); + value.Data.SingleValue.Should().NotBeNull(); + value.Data.SingleValue.Type.Should().Be("people"); + value.Data.SingleValue.Id.Should().Be(todoItems[1].Assignee!.StringId); + }); + + relationships.Should().ContainKey("tags").WhoseValue.With(value => + { + value.Should().NotBeNull(); + value.Data.ManyValue.Should().HaveCount(2); + value.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("tags")); + value.Data.ManyValue[0].Id.Should().Be(todoItems[1].Tags.ElementAt(0).StringId); + value.Data.ManyValue[1].Id.Should().Be(todoItems[1].Tags.ElementAt(1).StringId); + }); + }); + + responseDocument.Included.Should().HaveCount(6); + + responseDocument.Included[0].Type.Should().Be("people"); + responseDocument.Included[0].Id.Should().Be(owner.StringId); + responseDocument.Included[0].Attributes.Should().ContainKey("firstName").WhoseValue.Should().Be(owner.FirstName); + responseDocument.Included[0].Attributes.Should().ContainKey("lastName").WhoseValue.Should().Be(owner.LastName); + + responseDocument.Included[1].Type.Should().Be("tags"); + responseDocument.Included[1].Id.Should().Be(todoItems[0].Tags.ElementAt(0).StringId); + responseDocument.Included[1].Attributes.Should().ContainKey("name").WhoseValue.Should().Be(todoItems[0].Tags.ElementAt(0).Name); + + responseDocument.Included[2].Type.Should().Be("tags"); + responseDocument.Included[2].Id.Should().Be(todoItems[0].Tags.ElementAt(1).StringId); + responseDocument.Included[2].Attributes.Should().ContainKey("name").WhoseValue.Should().Be(todoItems[0].Tags.ElementAt(1).Name); + + responseDocument.Included[3].Type.Should().Be("people"); + responseDocument.Included[3].Id.Should().Be(todoItems[1].Assignee!.StringId); + responseDocument.Included[3].Attributes.Should().ContainKey("firstName").WhoseValue.Should().Be(todoItems[1].Assignee!.FirstName); + responseDocument.Included[3].Attributes.Should().ContainKey("lastName").WhoseValue.Should().Be(todoItems[1].Assignee!.LastName); + + responseDocument.Included[4].Type.Should().Be("tags"); + responseDocument.Included[4].Id.Should().Be(todoItems[1].Tags.ElementAt(0).StringId); + responseDocument.Included[4].Attributes.Should().ContainKey("name").WhoseValue.Should().Be(todoItems[1].Tags.ElementAt(0).Name); + + responseDocument.Included[5].Type.Should().Be("tags"); + responseDocument.Included[5].Id.Should().Be(todoItems[1].Tags.ElementAt(1).StringId); + responseDocument.Included[5].Attributes.Should().ContainKey("name").WhoseValue.Should().Be(todoItems[1].Tags.ElementAt(1).Name); + + responseDocument.Meta.Should().ContainTotal(2); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + """)); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority", t2."Id", t2."FirstName", t2."LastName", t3."Id", t3."FirstName", t3."LastName", t4."Id", t4."CreatedAt", t4."Description", t4."DurationInHours", t4."LastModifiedAt", t4."Priority", t5."Id", t5."Name" + FROM "TodoItems" AS t1 + LEFT JOIN "People" AS t2 ON t1."AssigneeId" = t2."Id" + INNER JOIN "People" AS t3 ON t1."OwnerId" = t3."Id" + LEFT JOIN "TodoItems" AS t4 ON t3."Id" = t4."AssigneeId" + LEFT JOIN "Tags" AS t5 ON t1."Id" = t5."TodoItemId" + ORDER BY t1."Priority", t1."LastModifiedAt" DESC, t4."Priority", t4."LastModifiedAt" DESC + """)); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_get_primary_resources_with_includes() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.GenerateList(25); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.GenerateOne()); + todoItems.ForEach(todoItem => todoItem.Tags = _fakers.Tag.GenerateSet(15)); + todoItems.ForEach(todoItem => todoItem.Tags.ForEach(tag => tag.Color = _fakers.RgbColor.GenerateOne())); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?include=tags.color"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(25); + + responseDocument.Data.ManyValue.ForEach(resource => + { + resource.Type.Should().Be("todoItems"); + resource.Attributes.Should().OnlyContainKeys("description", "priority", "durationInHours", "createdAt", "modifiedAt"); + resource.Relationships.Should().OnlyContainKeys("owner", "assignee", "tags"); + }); + + responseDocument.Included.Should().HaveCount(25 * 15 * 2); + + responseDocument.Meta.Should().ContainTotal(25); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + """)); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority", t2."Id", t2."Name", t3."Id" + FROM "TodoItems" AS t1 + LEFT JOIN "Tags" AS t2 ON t1."Id" = t2."TodoItemId" + LEFT JOIN "RgbColors" AS t3 ON t2."Id" = t3."TagId" + ORDER BY t1."Priority", t1."LastModifiedAt" DESC + """)); + + command.Parameters.Should().BeEmpty(); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/QueryStrings/PaginationTests.cs b/test/DapperTests/IntegrationTests/QueryStrings/PaginationTests.cs new file mode 100644 index 0000000000..854fb176db --- /dev/null +++ b/test/DapperTests/IntegrationTests/QueryStrings/PaginationTests.cs @@ -0,0 +1,52 @@ +using System.Net; +using DapperExample.Models; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.QueryStrings; + +public sealed class PaginationTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public PaginationTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Cannot_use_pagination() + { + // Arrange + TodoItem todoItem = _fakers.TodoItem.GenerateOne(); + todoItem.Owner = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?page[size]=3"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.InternalServerError); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.InternalServerError); + error.Title.Should().Be("An unhandled error occurred while processing this request."); + error.Detail.Should().Be("Pagination is not supported."); + error.Source.Should().BeNull(); + } +} diff --git a/test/DapperTests/IntegrationTests/QueryStrings/SortTests.cs b/test/DapperTests/IntegrationTests/QueryStrings/SortTests.cs new file mode 100644 index 0000000000..6a155d1524 --- /dev/null +++ b/test/DapperTests/IntegrationTests/QueryStrings/SortTests.cs @@ -0,0 +1,428 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.QueryStrings; + +public sealed class SortTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public SortTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_sort_on_attributes_in_primary_resources() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.GenerateList(3); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.GenerateOne()); + + todoItems[0].Description = "B"; + todoItems[1].Description = "A"; + todoItems[2].Description = "C"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?sort=-description,durationInHours,id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(3); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("todoItems")); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItems[2].StringId); + responseDocument.Data.ManyValue[1].Id.Should().Be(todoItems[0].StringId); + responseDocument.Data.ManyValue[2].Id.Should().Be(todoItems[1].StringId); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + """)); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + ORDER BY t1."Description" DESC, t1."DurationInHours", t1."Id" + """)); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_sort_on_attributes_in_secondary_and_included_resources() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.GenerateOne(); + person.OwnedTodoItems = _fakers.TodoItem.GenerateSet(3); + + person.OwnedTodoItems.ElementAt(0).DurationInHours = 40; + person.OwnedTodoItems.ElementAt(1).DurationInHours = 100; + person.OwnedTodoItems.ElementAt(2).DurationInHours = 250; + + person.OwnedTodoItems.ElementAt(1).Tags = _fakers.Tag.GenerateSet(2); + + person.OwnedTodoItems.ElementAt(1).Tags.ElementAt(0).Name = "B"; + person.OwnedTodoItems.ElementAt(1).Tags.ElementAt(1).Name = "A"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.AddRange(person); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/people/{person.StringId}/ownedTodoItems?include=tags&sort=-durationInHours&sort[tags]=name"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(3); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("todoItems")); + responseDocument.Data.ManyValue[0].Id.Should().Be(person.OwnedTodoItems.ElementAt(2).StringId); + responseDocument.Data.ManyValue[1].Id.Should().Be(person.OwnedTodoItems.ElementAt(1).StringId); + responseDocument.Data.ManyValue[2].Id.Should().Be(person.OwnedTodoItems.ElementAt(0).StringId); + + responseDocument.Included.Should().HaveCount(2); + responseDocument.Included.Should().AllSatisfy(resource => resource.Type.Should().Be("tags")); + responseDocument.Included[0].Id.Should().Be(person.OwnedTodoItems.ElementAt(1).Tags.ElementAt(1).StringId); + responseDocument.Included[1].Id.Should().Be(person.OwnedTodoItems.ElementAt(1).Tags.ElementAt(0).StringId); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + INNER JOIN "People" AS t2 ON t1."OwnerId" = t2."Id" + WHERE t2."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."Priority", t3."Id", t3."Name" + FROM "People" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."Id" = t2."OwnerId" + LEFT JOIN "Tags" AS t3 ON t2."Id" = t3."TodoItemId" + WHERE t1."Id" = @p1 + ORDER BY t2."DurationInHours" DESC, t3."Name" + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + } + + [Fact] + public async Task Can_sort_on_count_in_primary_resources() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.GenerateList(3); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.GenerateOne()); + + todoItems[0].Tags = _fakers.Tag.GenerateSet(2); + todoItems[1].Tags = _fakers.Tag.GenerateSet(1); + todoItems[2].Tags = _fakers.Tag.GenerateSet(3); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?sort=-count(tags),id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(3); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("todoItems")); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItems[2].StringId); + responseDocument.Data.ManyValue[1].Id.Should().Be(todoItems[0].StringId); + responseDocument.Data.ManyValue[2].Id.Should().Be(todoItems[1].StringId); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + """)); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + ORDER BY ( + SELECT COUNT(*) + FROM "Tags" AS t2 + WHERE t1."Id" = t2."TodoItemId" + ) DESC, t1."Id" + """)); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_sort_on_count_in_secondary_resources() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.GenerateOne(); + person.OwnedTodoItems = _fakers.TodoItem.GenerateSet(3); + + person.OwnedTodoItems.ElementAt(0).Tags = _fakers.Tag.GenerateSet(2); + person.OwnedTodoItems.ElementAt(1).Tags = _fakers.Tag.GenerateSet(1); + person.OwnedTodoItems.ElementAt(2).Tags = _fakers.Tag.GenerateSet(3); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.AddRange(person); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/people/{person.StringId}/ownedTodoItems?sort=-count(tags),id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(3); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("todoItems")); + responseDocument.Data.ManyValue[0].Id.Should().Be(person.OwnedTodoItems.ElementAt(2).StringId); + responseDocument.Data.ManyValue[1].Id.Should().Be(person.OwnedTodoItems.ElementAt(0).StringId); + responseDocument.Data.ManyValue[2].Id.Should().Be(person.OwnedTodoItems.ElementAt(1).StringId); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + INNER JOIN "People" AS t2 ON t1."OwnerId" = t2."Id" + WHERE t2."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."Priority" + FROM "People" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."Id" = t2."OwnerId" + WHERE t1."Id" = @p1 + ORDER BY ( + SELECT COUNT(*) + FROM "Tags" AS t3 + WHERE t2."Id" = t3."TodoItemId" + ) DESC, t2."Id" + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + } + + [Fact] + public async Task Can_sort_on_count_in_secondary_resources_with_include() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.GenerateOne(); + person.OwnedTodoItems = _fakers.TodoItem.GenerateSet(3); + + person.OwnedTodoItems.ElementAt(0).Tags = _fakers.Tag.GenerateSet(2); + person.OwnedTodoItems.ElementAt(1).Tags = _fakers.Tag.GenerateSet(1); + person.OwnedTodoItems.ElementAt(2).Tags = _fakers.Tag.GenerateSet(3); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.AddRange(person); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/people/{person.StringId}/ownedTodoItems?include=tags&sort=-count(tags),id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(3); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("todoItems")); + responseDocument.Data.ManyValue[0].Id.Should().Be(person.OwnedTodoItems.ElementAt(2).StringId); + responseDocument.Data.ManyValue[1].Id.Should().Be(person.OwnedTodoItems.ElementAt(0).StringId); + responseDocument.Data.ManyValue[2].Id.Should().Be(person.OwnedTodoItems.ElementAt(1).StringId); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + INNER JOIN "People" AS t2 ON t1."OwnerId" = t2."Id" + WHERE t2."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."Priority", t4."Id", t4."Name" + FROM "People" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."Id" = t2."OwnerId" + LEFT JOIN "Tags" AS t4 ON t2."Id" = t4."TodoItemId" + WHERE t1."Id" = @p1 + ORDER BY ( + SELECT COUNT(*) + FROM "Tags" AS t3 + WHERE t2."Id" = t3."TodoItemId" + ) DESC, t2."Id" + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + } + + [Fact] + public async Task Can_sort_on_count_in_included_resources() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.GenerateOne(); + person.OwnedTodoItems = _fakers.TodoItem.GenerateSet(4); + + person.OwnedTodoItems.ElementAt(0).Tags = _fakers.Tag.GenerateSet(2); + person.OwnedTodoItems.ElementAt(1).Tags = _fakers.Tag.GenerateSet(1); + person.OwnedTodoItems.ElementAt(2).Tags = _fakers.Tag.GenerateSet(3); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.AddRange(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?include=ownedTodoItems&sort[ownedTodoItems]=-count(tags),id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("people"); + responseDocument.Data.ManyValue[0].Id.Should().Be(person.StringId); + + responseDocument.Included.Should().HaveCount(4); + responseDocument.Included.Should().AllSatisfy(resource => resource.Type.Should().Be("todoItems")); + responseDocument.Included[0].Id.Should().Be(person.OwnedTodoItems.ElementAt(2).StringId); + responseDocument.Included[1].Id.Should().Be(person.OwnedTodoItems.ElementAt(0).StringId); + responseDocument.Included[2].Id.Should().Be(person.OwnedTodoItems.ElementAt(1).StringId); + responseDocument.Included[3].Id.Should().Be(person.OwnedTodoItems.ElementAt(3).StringId); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "People" AS t1 + """)); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."Priority" + FROM "People" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."Id" = t2."OwnerId" + ORDER BY ( + SELECT COUNT(*) + FROM "Tags" AS t3 + WHERE t2."Id" = t3."TodoItemId" + ) DESC, t2."Id" + """)); + + command.Parameters.Should().BeEmpty(); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/QueryStrings/SparseFieldSets.cs b/test/DapperTests/IntegrationTests/QueryStrings/SparseFieldSets.cs new file mode 100644 index 0000000000..a1d4524c92 --- /dev/null +++ b/test/DapperTests/IntegrationTests/QueryStrings/SparseFieldSets.cs @@ -0,0 +1,408 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.QueryStrings; + +public sealed class SparseFieldSets : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public SparseFieldSets(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_select_fields_in_primary_and_included_resources() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.GenerateOne(); + todoItem.Owner = _fakers.Person.GenerateOne(); + todoItem.Assignee = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems?include=owner,assignee&fields[todoItems]=description,durationInHours,owner,assignee&fields[people]=lastName"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("todoItems"); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItem.StringId); + responseDocument.Data.ManyValue[0].Attributes.Should().HaveCount(2); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("description").WhoseValue.Should().Be(todoItem.Description); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("durationInHours").WhoseValue.Should().Be(todoItem.DurationInHours); + responseDocument.Data.ManyValue[0].Relationships.Should().HaveCount(2); + + responseDocument.Data.ManyValue[0].Relationships.Should().ContainKey("owner").WhoseValue.With(value => + { + value.Should().NotBeNull(); + value.Data.SingleValue.Should().NotBeNull(); + value.Data.SingleValue.Type.Should().Be("people"); + value.Data.SingleValue.Id.Should().Be(todoItem.Owner.StringId); + }); + + responseDocument.Data.ManyValue[0].Relationships.Should().ContainKey("assignee").WhoseValue.With(value => + { + value.Should().NotBeNull(); + value.Data.SingleValue.Should().NotBeNull(); + value.Data.SingleValue.Type.Should().Be("people"); + value.Data.SingleValue.Id.Should().Be(todoItem.Assignee.StringId); + }); + + responseDocument.Included.Should().HaveCount(2); + responseDocument.Included.Should().AllSatisfy(resource => resource.Type.Should().Be("people")); + + responseDocument.Included[0].Id.Should().Be(todoItem.Owner.StringId); + responseDocument.Included[0].Attributes.Should().HaveCount(1); + responseDocument.Included[0].Attributes.Should().ContainKey("lastName").WhoseValue.Should().Be(todoItem.Owner.LastName); + responseDocument.Included[0].Relationships.Should().BeNull(); + + responseDocument.Included[1].Id.Should().Be(todoItem.Assignee.StringId); + responseDocument.Included[1].Attributes.Should().HaveCount(1); + responseDocument.Included[1].Attributes.Should().ContainKey("lastName").WhoseValue.Should().Be(todoItem.Assignee.LastName); + responseDocument.Included[1].Relationships.Should().BeNull(); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + """)); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."Description", t1."DurationInHours", t2."Id", t2."LastName", t3."Id", t3."LastName" + FROM "TodoItems" AS t1 + LEFT JOIN "People" AS t2 ON t1."AssigneeId" = t2."Id" + INNER JOIN "People" AS t3 ON t1."OwnerId" = t3."Id" + ORDER BY t1."Priority", t1."LastModifiedAt" DESC + """)); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_select_attribute_in_primary_resource() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.GenerateOne(); + todoItem.Owner = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{todoItem.StringId}?fields[todoItems]=description"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("todoItems"); + responseDocument.Data.SingleValue.Id.Should().Be(todoItem.StringId); + responseDocument.Data.SingleValue.Attributes.Should().HaveCount(1); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("description").WhoseValue.Should().Be(todoItem.Description); + responseDocument.Data.SingleValue.Relationships.Should().BeNull(); + + store.SqlCommands.Should().HaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."Description" + FROM "TodoItems" AS t1 + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + } + + [Fact] + public async Task Can_select_relationship_in_secondary_resources() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.GenerateOne(); + todoItem.Owner = _fakers.Person.GenerateOne(); + todoItem.Tags = _fakers.Tag.GenerateSet(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{todoItem.StringId}/tags?fields[tags]=color"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("tags"); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItem.Tags.ElementAt(0).StringId); + responseDocument.Data.ManyValue[0].Attributes.Should().BeNull(); + responseDocument.Data.ManyValue[0].Relationships.Should().HaveCount(1); + + responseDocument.Data.ManyValue[0].Relationships.Should().ContainKey("color").WhoseValue.With(value => + { + value.Should().NotBeNull(); + value.Data.Value.Should().BeNull(); + }); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "Tags" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."TodoItemId" = t2."Id" + WHERE t2."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t2."Id" + FROM "TodoItems" AS t1 + LEFT JOIN "Tags" AS t2 ON t1."Id" = t2."TodoItemId" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + } + + [Fact] + public async Task Can_select_ID() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/people/{person.StringId}?fields[people]=id"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("people"); + responseDocument.Data.SingleValue.Id.Should().Be(person.StringId); + responseDocument.Data.SingleValue.Attributes.Should().BeNull(); + responseDocument.Data.SingleValue.Relationships.Should().BeNull(); + + store.SqlCommands.Should().HaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id" + FROM "People" AS t1 + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + } + + [Fact] + public async Task Can_select_empty_fieldset() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/people/{person.StringId}?fields[people]="; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("people"); + responseDocument.Data.SingleValue.Id.Should().Be(person.StringId); + responseDocument.Data.SingleValue.Attributes.Should().BeNull(); + responseDocument.Data.SingleValue.Relationships.Should().BeNull(); + + store.SqlCommands.Should().HaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id" + FROM "People" AS t1 + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + } + + [Fact] + public async Task Fetches_all_scalar_properties_when_fieldset_contains_readonly_attribute() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/people/{person.StringId}?fields[people]=displayName"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("people"); + responseDocument.Data.SingleValue.Id.Should().Be(person.StringId); + responseDocument.Data.SingleValue.Attributes.Should().HaveCount(1); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("displayName").WhoseValue.Should().Be(person.DisplayName); + responseDocument.Data.SingleValue.Relationships.Should().BeNull(); + + store.SqlCommands.Should().HaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName" + FROM "People" AS t1 + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", person.Id); + }); + } + + [Fact] + public async Task Returns_related_resources_on_broken_resource_linkage() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.GenerateOne(); + todoItem.Owner = _fakers.Person.GenerateOne(); + todoItem.Tags = _fakers.Tag.GenerateSet(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{todoItem.StringId}?include=tags&fields[todoItems]=description"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("todoItems"); + responseDocument.Data.SingleValue.Id.Should().Be(todoItem.StringId); + responseDocument.Data.SingleValue.Attributes.Should().HaveCount(1); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("description").WhoseValue.Should().Be(todoItem.Description); + responseDocument.Data.SingleValue.Relationships.Should().BeNull(); + + responseDocument.Included.Should().HaveCount(2); + responseDocument.Included.Should().AllSatisfy(resource => resource.Type.Should().Be("tags")); + + store.SqlCommands.Should().HaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."Description", t2."Id", t2."Name" + FROM "TodoItems" AS t1 + LEFT JOIN "Tags" AS t2 ON t1."Id" = t2."TodoItemId" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Relationships/AddToToManyRelationshipTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/AddToToManyRelationshipTests.cs new file mode 100644 index 0000000000..6f5a87ea33 --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/AddToToManyRelationshipTests.cs @@ -0,0 +1,95 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.ReadWrite.Relationships; + +public sealed class AddToToManyRelationshipTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public AddToToManyRelationshipTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_add_to_OneToMany_relationship() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson = _fakers.Person.GenerateOne(); + existingPerson.OwnedTodoItems = _fakers.TodoItem.GenerateSet(1); + + List existingTodoItems = _fakers.TodoItem.GenerateList(2); + existingTodoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.GenerateOne()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(existingPerson); + dbContext.TodoItems.AddRange(existingTodoItems); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "todoItems", + id = existingTodoItems.ElementAt(0).StringId + }, + new + { + type = "todoItems", + id = existingTodoItems.ElementAt(1).StringId + } + } + }; + + string route = $"/people/{existingPerson.StringId}/relationships/ownedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.Include(person => person.OwnedTodoItems).FirstWithIdAsync(existingPerson.Id); + + personInDatabase.OwnedTodoItems.Should().HaveCount(3); + }); + + store.SqlCommands.Should().HaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "TodoItems" + SET "OwnerId" = @p1 + WHERE "Id" IN (@p2, @p3) + """)); + + command.Parameters.Should().HaveCount(3); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + command.Parameters.Should().Contain("@p2", existingTodoItems.ElementAt(0).Id); + command.Parameters.Should().Contain("@p3", existingTodoItems.ElementAt(1).Id); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Relationships/FetchRelationshipTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/FetchRelationshipTests.cs new file mode 100644 index 0000000000..9783672e63 --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/FetchRelationshipTests.cs @@ -0,0 +1,198 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.ReadWrite.Relationships; + +public sealed class FetchRelationshipTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public FetchRelationshipTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_get_ToOne_relationship() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.GenerateOne(); + todoItem.Owner = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{todoItem.StringId}/relationships/owner"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("people"); + responseDocument.Data.SingleValue.Id.Should().Be(todoItem.Owner.StringId); + + responseDocument.Meta.Should().BeNull(); + + store.SqlCommands.Should().HaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t2."Id" + FROM "TodoItems" AS t1 + INNER JOIN "People" AS t2 ON t1."OwnerId" = t2."Id" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + } + + [Fact] + public async Task Can_get_empty_ToOne_relationship() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.GenerateOne(); + todoItem.Owner = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{todoItem.StringId}/relationships/assignee"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.Value.Should().BeNull(); + + responseDocument.Meta.Should().BeNull(); + + store.SqlCommands.Should().HaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t2."Id" + FROM "TodoItems" AS t1 + LEFT JOIN "People" AS t2 ON t1."AssigneeId" = t2."Id" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + } + + [Fact] + public async Task Can_get_ToMany_relationship() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.GenerateOne(); + todoItem.Owner = _fakers.Person.GenerateOne(); + todoItem.Tags = _fakers.Tag.GenerateSet(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{todoItem.StringId}/relationships/tags"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("tags")); + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItem.Tags.ElementAt(0).StringId); + responseDocument.Data.ManyValue[1].Id.Should().Be(todoItem.Tags.ElementAt(1).StringId); + + responseDocument.Meta.Should().ContainTotal(2); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "Tags" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."TodoItemId" = t2."Id" + WHERE t2."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t2."Id" + FROM "TodoItems" AS t1 + LEFT JOIN "Tags" AS t2 ON t1."Id" = t2."TodoItemId" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + } + + [Fact] + public async Task Cannot_get_relationship_for_unknown_primary_ID() + { + const long unknownTodoItemId = Unknown.TypedId.Int64; + + string route = $"/todoItems/{unknownTodoItemId}/relationships/owner"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'todoItems' with ID '{unknownTodoItemId}' does not exist."); + } +} diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Relationships/RemoveFromToManyRelationshipTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/RemoveFromToManyRelationshipTests.cs new file mode 100644 index 0000000000..ac6be3134e --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/RemoveFromToManyRelationshipTests.cs @@ -0,0 +1,232 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.ReadWrite.Relationships; + +public sealed class RemoveFromToManyRelationshipTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public RemoveFromToManyRelationshipTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_remove_from_OneToMany_relationship_with_nullable_foreign_key() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson = _fakers.Person.GenerateOne(); + existingPerson.AssignedTodoItems = _fakers.TodoItem.GenerateSet(3); + existingPerson.AssignedTodoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.GenerateOne()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(existingPerson); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "todoItems", + id = existingPerson.AssignedTodoItems.ElementAt(0).StringId + }, + new + { + type = "todoItems", + id = existingPerson.AssignedTodoItems.ElementAt(2).StringId + } + } + }; + + string route = $"/people/{existingPerson.StringId}/relationships/assignedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.Include(person => person.AssignedTodoItems).FirstWithIdAsync(existingPerson.Id); + + personInDatabase.AssignedTodoItems.Should().HaveCount(1); + personInDatabase.AssignedTodoItems.ElementAt(0).Id.Should().Be(existingPerson.AssignedTodoItems.ElementAt(1).Id); + + List todoItemInDatabases = await dbContext.TodoItems.Where(todoItem => todoItem.Assignee == null).ToListAsync(); + + todoItemInDatabases.Should().HaveCount(2); + }); + + store.SqlCommands.Should().HaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t3."Id" + FROM "People" AS t1 + LEFT JOIN ( + SELECT t2."Id", t2."AssigneeId" + FROM "TodoItems" AS t2 + WHERE t2."Id" IN (@p2, @p3) + ) AS t3 ON t1."Id" = t3."AssigneeId" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(3); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + command.Parameters.Should().Contain("@p2", existingPerson.AssignedTodoItems.ElementAt(0).Id); + command.Parameters.Should().Contain("@p3", existingPerson.AssignedTodoItems.ElementAt(2).Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id" + FROM "TodoItems" AS t1 + WHERE t1."Id" IN (@p1, @p2) + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", existingPerson.AssignedTodoItems.ElementAt(0).Id); + command.Parameters.Should().Contain("@p2", existingPerson.AssignedTodoItems.ElementAt(2).Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "TodoItems" + SET "AssigneeId" = @p1 + WHERE "Id" IN (@p2, @p3) + """)); + + command.Parameters.Should().HaveCount(3); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", existingPerson.AssignedTodoItems.ElementAt(0).Id); + command.Parameters.Should().Contain("@p3", existingPerson.AssignedTodoItems.ElementAt(2).Id); + }); + } + + [Fact] + public async Task Can_remove_from_OneToMany_relationship_with_required_foreign_key() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson = _fakers.Person.GenerateOne(); + existingPerson.OwnedTodoItems = _fakers.TodoItem.GenerateSet(3); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(existingPerson); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "todoItems", + id = existingPerson.OwnedTodoItems.ElementAt(0).StringId + }, + new + { + type = "todoItems", + id = existingPerson.OwnedTodoItems.ElementAt(2).StringId + } + } + }; + + string route = $"/people/{existingPerson.StringId}/relationships/ownedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.Include(person => person.OwnedTodoItems).FirstWithIdAsync(existingPerson.Id); + + personInDatabase.OwnedTodoItems.Should().HaveCount(1); + personInDatabase.OwnedTodoItems.ElementAt(0).Id.Should().Be(existingPerson.OwnedTodoItems.ElementAt(1).Id); + + List todoItemInDatabases = await dbContext.TodoItems.ToListAsync(); + + todoItemInDatabases.Should().HaveCount(1); + }); + + store.SqlCommands.Should().HaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t3."Id" + FROM "People" AS t1 + LEFT JOIN ( + SELECT t2."Id", t2."OwnerId" + FROM "TodoItems" AS t2 + WHERE t2."Id" IN (@p2, @p3) + ) AS t3 ON t1."Id" = t3."OwnerId" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(3); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + command.Parameters.Should().Contain("@p2", existingPerson.OwnedTodoItems.ElementAt(0).Id); + command.Parameters.Should().Contain("@p3", existingPerson.OwnedTodoItems.ElementAt(2).Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id" + FROM "TodoItems" AS t1 + WHERE t1."Id" IN (@p1, @p2) + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", existingPerson.OwnedTodoItems.ElementAt(0).Id); + command.Parameters.Should().Contain("@p2", existingPerson.OwnedTodoItems.ElementAt(2).Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + DELETE FROM "TodoItems" + WHERE "Id" IN (@p1, @p2) + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", existingPerson.OwnedTodoItems.ElementAt(0).Id); + command.Parameters.Should().Contain("@p2", existingPerson.OwnedTodoItems.ElementAt(2).Id); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Relationships/ReplaceToManyRelationshipTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/ReplaceToManyRelationshipTests.cs new file mode 100644 index 0000000000..a46a34678b --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/ReplaceToManyRelationshipTests.cs @@ -0,0 +1,421 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.ReadWrite.Relationships; + +public sealed class ReplaceToManyRelationshipTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public ReplaceToManyRelationshipTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_clear_OneToMany_relationship_with_nullable_foreign_key() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson = _fakers.Person.GenerateOne(); + existingPerson.AssignedTodoItems = _fakers.TodoItem.GenerateSet(2); + existingPerson.AssignedTodoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.GenerateOne()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(existingPerson); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = Array.Empty() + }; + + string route = $"/people/{existingPerson.StringId}/relationships/assignedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.Include(person => person.AssignedTodoItems).FirstWithIdAsync(existingPerson.Id); + + personInDatabase.AssignedTodoItems.Should().BeEmpty(); + }); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."Priority" + FROM "People" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."Id" = t2."AssigneeId" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "TodoItems" + SET "AssigneeId" = @p1 + WHERE "Id" IN (@p2, @p3) + """)); + + command.Parameters.Should().HaveCount(3); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", existingPerson.AssignedTodoItems.ElementAt(0).Id); + command.Parameters.Should().Contain("@p3", existingPerson.AssignedTodoItems.ElementAt(1).Id); + }); + } + + [Fact] + public async Task Can_clear_OneToMany_relationship_with_required_foreign_key() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson = _fakers.Person.GenerateOne(); + existingPerson.OwnedTodoItems = _fakers.TodoItem.GenerateSet(2); + existingPerson.OwnedTodoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.GenerateOne()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(existingPerson); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = Array.Empty() + }; + + string route = $"/people/{existingPerson.StringId}/relationships/ownedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.Include(person => person.OwnedTodoItems).FirstWithIdAsync(existingPerson.Id); + + personInDatabase.OwnedTodoItems.Should().BeEmpty(); + }); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."Priority" + FROM "People" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."Id" = t2."OwnerId" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + DELETE FROM "TodoItems" + WHERE "Id" IN (@p1, @p2) + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", existingPerson.OwnedTodoItems.ElementAt(0).Id); + command.Parameters.Should().Contain("@p2", existingPerson.OwnedTodoItems.ElementAt(1).Id); + }); + } + + [Fact] + public async Task Can_create_OneToMany_relationship() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson = _fakers.Person.GenerateOne(); + + List existingTodoItems = _fakers.TodoItem.GenerateList(2); + existingTodoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.GenerateOne()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(existingPerson); + dbContext.TodoItems.AddRange(existingTodoItems); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "todoItems", + id = existingTodoItems.ElementAt(0).StringId + }, + new + { + type = "todoItems", + id = existingTodoItems.ElementAt(1).StringId + } + } + }; + + string route = $"/people/{existingPerson.StringId}/relationships/assignedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.Include(person => person.AssignedTodoItems).FirstWithIdAsync(existingPerson.Id); + + personInDatabase.AssignedTodoItems.Should().HaveCount(2); + personInDatabase.AssignedTodoItems.ElementAt(0).Id.Should().Be(existingTodoItems.ElementAt(0).Id); + personInDatabase.AssignedTodoItems.ElementAt(1).Id.Should().Be(existingTodoItems.ElementAt(1).Id); + }); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."Priority" + FROM "People" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."Id" = t2."AssigneeId" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "TodoItems" + SET "AssigneeId" = @p1 + WHERE "Id" IN (@p2, @p3) + """)); + + command.Parameters.Should().HaveCount(3); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + command.Parameters.Should().Contain("@p2", existingTodoItems.ElementAt(0).Id); + command.Parameters.Should().Contain("@p3", existingTodoItems.ElementAt(1).Id); + }); + } + + [Fact] + public async Task Can_replace_OneToMany_relationship_with_nullable_foreign_key() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson = _fakers.Person.GenerateOne(); + existingPerson.AssignedTodoItems = _fakers.TodoItem.GenerateSet(1); + existingPerson.AssignedTodoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.GenerateOne()); + + TodoItem existingTodoItem = _fakers.TodoItem.GenerateOne(); + existingTodoItem.Owner = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingPerson, existingTodoItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "todoItems", + id = existingTodoItem.StringId + } + } + }; + + string route = $"/people/{existingPerson.StringId}/relationships/assignedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.Include(person => person.AssignedTodoItems).FirstWithIdAsync(existingPerson.Id); + + personInDatabase.AssignedTodoItems.Should().HaveCount(1); + personInDatabase.AssignedTodoItems.ElementAt(0).Id.Should().Be(existingTodoItem.Id); + }); + + store.SqlCommands.Should().HaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."Priority" + FROM "People" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."Id" = t2."AssigneeId" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "TodoItems" + SET "AssigneeId" = @p1 + WHERE "Id" = @p2 + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", existingPerson.AssignedTodoItems.ElementAt(0).Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "TodoItems" + SET "AssigneeId" = @p1 + WHERE "Id" = @p2 + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + command.Parameters.Should().Contain("@p2", existingTodoItem.Id); + }); + } + + [Fact] + public async Task Can_replace_OneToMany_relationship_with_required_foreign_key() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson = _fakers.Person.GenerateOne(); + existingPerson.OwnedTodoItems = _fakers.TodoItem.GenerateSet(1); + existingPerson.OwnedTodoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.GenerateOne()); + + TodoItem existingTodoItem = _fakers.TodoItem.GenerateOne(); + existingTodoItem.Owner = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingPerson, existingTodoItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "todoItems", + id = existingTodoItem.StringId + } + } + }; + + string route = $"/people/{existingPerson.StringId}/relationships/ownedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.Include(person => person.OwnedTodoItems).FirstWithIdAsync(existingPerson.Id); + + personInDatabase.OwnedTodoItems.Should().HaveCount(1); + personInDatabase.OwnedTodoItems.ElementAt(0).Id.Should().Be(existingTodoItem.Id); + }); + + store.SqlCommands.Should().HaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."Priority" + FROM "People" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."Id" = t2."OwnerId" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + DELETE FROM "TodoItems" + WHERE "Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingPerson.OwnedTodoItems.ElementAt(0).Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "TodoItems" + SET "OwnerId" = @p1 + WHERE "Id" = @p2 + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + command.Parameters.Should().Contain("@p2", existingTodoItem.Id); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Relationships/UpdateToOneRelationshipTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/UpdateToOneRelationshipTests.cs new file mode 100644 index 0000000000..bab60bf29b --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Relationships/UpdateToOneRelationshipTests.cs @@ -0,0 +1,1190 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.ReadWrite.Relationships; + +public sealed class UpdateToOneRelationshipTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public UpdateToOneRelationshipTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_clear_OneToOne_relationship_with_nullable_foreign_key_at_left_side() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson = _fakers.Person.GenerateOne(); + existingPerson.Account = _fakers.LoginAccount.GenerateOne(); + existingPerson.Account.Recovery = _fakers.AccountRecovery.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(existingPerson); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object?)null + }; + + string route = $"/people/{existingPerson.StringId}/relationships/account"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.Include(person => person.Account).FirstWithIdAsync(existingPerson.Id); + + personInDatabase.Account.Should().BeNull(); + + LoginAccount loginAccountInDatabase = + await dbContext.LoginAccounts.Include(loginAccount => loginAccount.Person).FirstWithIdAsync(existingPerson.Account.Id); + + loginAccountInDatabase.Person.Should().BeNull(); + }); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t2."Id", t2."LastUsedAt", t2."UserName" + FROM "People" AS t1 + LEFT JOIN "LoginAccounts" AS t2 ON t1."AccountId" = t2."Id" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "People" + SET "AccountId" = @p1 + WHERE "Id" = @p2 + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", existingPerson.Id); + }); + } + + [Fact] + public async Task Can_clear_OneToOne_relationship_with_nullable_foreign_key_at_right_side() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + LoginAccount existingLoginAccount = _fakers.LoginAccount.GenerateOne(); + existingLoginAccount.Recovery = _fakers.AccountRecovery.GenerateOne(); + existingLoginAccount.Person = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.LoginAccounts.Add(existingLoginAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object?)null + }; + + string route = $"/loginAccounts/{existingLoginAccount.StringId}/relationships/person"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + LoginAccount loginAccountInDatabase = + await dbContext.LoginAccounts.Include(loginAccount => loginAccount.Person).FirstWithIdAsync(existingLoginAccount.Id); + + loginAccountInDatabase.Person.Should().BeNull(); + + Person personInDatabase = await dbContext.People.Include(person => person.Account).FirstWithIdAsync(existingLoginAccount.Person.Id); + + personInDatabase.Account.Should().BeNull(); + }); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."LastUsedAt", t1."UserName", t2."Id", t2."FirstName", t2."LastName" + FROM "LoginAccounts" AS t1 + LEFT JOIN "People" AS t2 ON t1."Id" = t2."AccountId" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingLoginAccount.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "People" + SET "AccountId" = @p1 + WHERE "Id" = @p2 + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", existingLoginAccount.Person.Id); + }); + } + + [Fact] + public async Task Can_clear_OneToOne_relationship_with_nullable_foreign_key_at_right_side_when_already_null() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + LoginAccount existingLoginAccount = _fakers.LoginAccount.GenerateOne(); + existingLoginAccount.Recovery = _fakers.AccountRecovery.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.LoginAccounts.Add(existingLoginAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object?)null + }; + + string route = $"/loginAccounts/{existingLoginAccount.StringId}/relationships/person"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + store.SqlCommands.Should().HaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."LastUsedAt", t1."UserName", t2."Id", t2."FirstName", t2."LastName" + FROM "LoginAccounts" AS t1 + LEFT JOIN "People" AS t2 ON t1."Id" = t2."AccountId" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingLoginAccount.Id); + }); + } + + [Fact] + public async Task Cannot_clear_OneToOne_relationship_with_required_foreign_key_at_left_side() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + LoginAccount existingLoginAccount = _fakers.LoginAccount.GenerateOne(); + existingLoginAccount.Recovery = _fakers.AccountRecovery.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.LoginAccounts.Add(existingLoginAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object?)null + }; + + string route = $"/loginAccounts/{existingLoginAccount.StringId}/relationships/recovery"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Failed to clear a required relationship."); + error.Detail.Should().Be("The relationship 'recovery' on resource type 'loginAccounts' cannot be cleared because it is a required relationship."); + error.Source.Should().BeNull(); + + store.SqlCommands.Should().HaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."LastUsedAt", t1."UserName", t2."Id", t2."EmailAddress", t2."PhoneNumber" + FROM "LoginAccounts" AS t1 + INNER JOIN "AccountRecoveries" AS t2 ON t1."RecoveryId" = t2."Id" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingLoginAccount.Id); + }); + } + + [Fact] + public async Task Cannot_clear_OneToOne_relationship_with_required_foreign_key_at_right_side() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + AccountRecovery existingAccountRecovery = _fakers.AccountRecovery.GenerateOne(); + existingAccountRecovery.Account = _fakers.LoginAccount.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AccountRecoveries.Add(existingAccountRecovery); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object?)null + }; + + string route = $"/accountRecoveries/{existingAccountRecovery.StringId}/relationships/account"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Failed to clear a required relationship."); + error.Detail.Should().Be("The relationship 'account' on resource type 'accountRecoveries' cannot be cleared because it is a required relationship."); + error.Source.Should().BeNull(); + + store.SqlCommands.Should().HaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."EmailAddress", t1."PhoneNumber", t2."Id", t2."LastUsedAt", t2."UserName" + FROM "AccountRecoveries" AS t1 + LEFT JOIN "LoginAccounts" AS t2 ON t1."Id" = t2."RecoveryId" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingAccountRecovery.Id); + }); + } + + [Fact] + public async Task Can_clear_ManyToOne_relationship_with_nullable_foreign_key() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem existingTodoItem = _fakers.TodoItem.GenerateOne(); + existingTodoItem.Owner = _fakers.Person.GenerateOne(); + existingTodoItem.Assignee = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(existingTodoItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object?)null + }; + + string route = $"/todoItems/{existingTodoItem.StringId}/relationships/assignee"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + TodoItem todoItemInDatabase = await dbContext.TodoItems.Include(todoItem => todoItem.Assignee).FirstWithIdAsync(existingTodoItem.Id); + + todoItemInDatabase.Assignee.Should().BeNull(); + }); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority", t2."Id", t2."FirstName", t2."LastName" + FROM "TodoItems" AS t1 + LEFT JOIN "People" AS t2 ON t1."AssigneeId" = t2."Id" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingTodoItem.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "TodoItems" + SET "AssigneeId" = @p1 + WHERE "Id" = @p2 + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", existingTodoItem.Id); + }); + } + + [Fact] + public async Task Cannot_clear_ManyToOne_relationship_with_required_foreign_key() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem existingTodoItem = _fakers.TodoItem.GenerateOne(); + existingTodoItem.Owner = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(existingTodoItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object?)null + }; + + string route = $"/todoItems/{existingTodoItem.StringId}/relationships/owner"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("Failed to clear a required relationship."); + error.Detail.Should().Be("The relationship 'owner' on resource type 'todoItems' cannot be cleared because it is a required relationship."); + error.Source.Should().BeNull(); + + store.SqlCommands.Should().HaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority", t2."Id", t2."FirstName", t2."LastName" + FROM "TodoItems" AS t1 + INNER JOIN "People" AS t2 ON t1."OwnerId" = t2."Id" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingTodoItem.Id); + }); + } + + [Fact] + public async Task Can_create_OneToOne_relationship_with_nullable_foreign_key_at_left_side() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson = _fakers.Person.GenerateOne(); + + LoginAccount existingLoginAccount = _fakers.LoginAccount.GenerateOne(); + existingLoginAccount.Recovery = _fakers.AccountRecovery.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingPerson, existingLoginAccount); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "loginAccounts", + id = existingLoginAccount.StringId + } + }; + + string route = $"/people/{existingPerson.StringId}/relationships/account"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.Include(person => person.Account).FirstWithIdAsync(existingPerson.Id); + + personInDatabase.Account.Should().NotBeNull(); + personInDatabase.Account.Id.Should().Be(existingLoginAccount.Id); + }); + + store.SqlCommands.Should().HaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t2."Id", t2."LastUsedAt", t2."UserName" + FROM "People" AS t1 + LEFT JOIN "LoginAccounts" AS t2 ON t1."AccountId" = t2."Id" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "People" + SET "AccountId" = @p1 + WHERE "AccountId" = @p2 + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", existingLoginAccount.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "People" + SET "AccountId" = @p1 + WHERE "Id" = @p2 + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", existingLoginAccount.Id); + command.Parameters.Should().Contain("@p2", existingPerson.Id); + }); + } + + [Fact] + public async Task Can_create_OneToOne_relationship_with_nullable_foreign_key_at_right_side() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + LoginAccount existingLoginAccount = _fakers.LoginAccount.GenerateOne(); + existingLoginAccount.Recovery = _fakers.AccountRecovery.GenerateOne(); + + Person existingPerson = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingLoginAccount, existingPerson); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "people", + id = existingPerson.StringId + } + }; + + string route = $"/loginAccounts/{existingLoginAccount.StringId}/relationships/person"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + LoginAccount loginAccountInDatabase = + await dbContext.LoginAccounts.Include(loginAccount => loginAccount.Person).FirstWithIdAsync(existingLoginAccount.Id); + + loginAccountInDatabase.Person.Should().NotBeNull(); + loginAccountInDatabase.Person.Id.Should().Be(existingPerson.Id); + }); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."LastUsedAt", t1."UserName", t2."Id", t2."FirstName", t2."LastName" + FROM "LoginAccounts" AS t1 + LEFT JOIN "People" AS t2 ON t1."Id" = t2."AccountId" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingLoginAccount.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "People" + SET "AccountId" = @p1 + WHERE "Id" = @p2 + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", existingLoginAccount.Id); + command.Parameters.Should().Contain("@p2", existingPerson.Id); + }); + } + + [Fact] + public async Task Can_create_ManyToOne_relationship_with_nullable_foreign_key() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem existingTodoItem = _fakers.TodoItem.GenerateOne(); + existingTodoItem.Owner = _fakers.Person.GenerateOne(); + + Person existingPerson = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingTodoItem, existingPerson); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "people", + id = existingPerson.StringId + } + }; + + string route = $"/todoItems/{existingTodoItem.StringId}/relationships/assignee"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + TodoItem todoItemInDatabase = await dbContext.TodoItems.Include(todoItem => todoItem.Assignee).FirstWithIdAsync(existingTodoItem.Id); + + todoItemInDatabase.Assignee.Should().NotBeNull(); + todoItemInDatabase.Assignee.Id.Should().Be(existingPerson.Id); + }); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority", t2."Id", t2."FirstName", t2."LastName" + FROM "TodoItems" AS t1 + LEFT JOIN "People" AS t2 ON t1."AssigneeId" = t2."Id" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingTodoItem.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "TodoItems" + SET "AssigneeId" = @p1 + WHERE "Id" = @p2 + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", existingPerson.Id); + command.Parameters.Should().Contain("@p2", existingTodoItem.Id); + }); + } + + [Fact] + public async Task Can_replace_OneToOne_relationship_with_nullable_foreign_key_at_left_side() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person existingPerson1 = _fakers.Person.GenerateOne(); + existingPerson1.Account = _fakers.LoginAccount.GenerateOne(); + existingPerson1.Account.Recovery = _fakers.AccountRecovery.GenerateOne(); + + Person existingPerson2 = _fakers.Person.GenerateOne(); + existingPerson2.Account = _fakers.LoginAccount.GenerateOne(); + existingPerson2.Account.Recovery = _fakers.AccountRecovery.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.AddRange(existingPerson1, existingPerson2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "loginAccounts", + id = existingPerson2.Account.StringId + } + }; + + string route = $"/people/{existingPerson1.StringId}/relationships/account"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase1 = await dbContext.People.Include(person => person.Account).FirstWithIdAsync(existingPerson1.Id); + + personInDatabase1.Account.Should().NotBeNull(); + personInDatabase1.Account.Id.Should().Be(existingPerson2.Account.Id); + + Person personInDatabase2 = await dbContext.People.Include(person => person.Account).FirstWithIdAsync(existingPerson2.Id); + + personInDatabase2.Account.Should().BeNull(); + }); + + store.SqlCommands.Should().HaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t2."Id", t2."LastUsedAt", t2."UserName" + FROM "People" AS t1 + LEFT JOIN "LoginAccounts" AS t2 ON t1."AccountId" = t2."Id" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingPerson1.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "People" + SET "AccountId" = @p1 + WHERE "AccountId" = @p2 + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", existingPerson2.Account.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "People" + SET "AccountId" = @p1 + WHERE "Id" = @p2 + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", existingPerson2.Account.Id); + command.Parameters.Should().Contain("@p2", existingPerson1.Id); + }); + } + + [Fact] + public async Task Can_replace_OneToOne_relationship_with_nullable_foreign_key_at_right_side() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + LoginAccount existingLoginAccount1 = _fakers.LoginAccount.GenerateOne(); + existingLoginAccount1.Recovery = _fakers.AccountRecovery.GenerateOne(); + existingLoginAccount1.Person = _fakers.Person.GenerateOne(); + + LoginAccount existingLoginAccount2 = _fakers.LoginAccount.GenerateOne(); + existingLoginAccount2.Recovery = _fakers.AccountRecovery.GenerateOne(); + existingLoginAccount2.Person = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.LoginAccounts.AddRange(existingLoginAccount1, existingLoginAccount2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "people", + id = existingLoginAccount2.Person.StringId + } + }; + + string route = $"/loginAccounts/{existingLoginAccount1.StringId}/relationships/person"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + LoginAccount loginAccountInDatabase1 = + await dbContext.LoginAccounts.Include(loginAccount => loginAccount.Person).FirstWithIdAsync(existingLoginAccount1.Id); + + loginAccountInDatabase1.Person.Should().NotBeNull(); + loginAccountInDatabase1.Person.Id.Should().Be(existingLoginAccount2.Person.Id); + + LoginAccount loginAccountInDatabase2 = + await dbContext.LoginAccounts.Include(loginAccount => loginAccount.Person).FirstWithIdAsync(existingLoginAccount2.Id); + + loginAccountInDatabase2.Person.Should().BeNull(); + }); + + store.SqlCommands.Should().HaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."LastUsedAt", t1."UserName", t2."Id", t2."FirstName", t2."LastName" + FROM "LoginAccounts" AS t1 + LEFT JOIN "People" AS t2 ON t1."Id" = t2."AccountId" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingLoginAccount1.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "People" + SET "AccountId" = @p1 + WHERE "Id" = @p2 + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", existingLoginAccount1.Person.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "People" + SET "AccountId" = @p1 + WHERE "Id" = @p2 + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", existingLoginAccount1.Id); + command.Parameters.Should().Contain("@p2", existingLoginAccount2.Person.Id); + }); + } + + [Fact] + public async Task Can_replace_OneToOne_relationship_with_required_foreign_key_at_left_side() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + LoginAccount existingLoginAccount1 = _fakers.LoginAccount.GenerateOne(); + existingLoginAccount1.Recovery = _fakers.AccountRecovery.GenerateOne(); + + LoginAccount existingLoginAccount2 = _fakers.LoginAccount.GenerateOne(); + existingLoginAccount2.Recovery = _fakers.AccountRecovery.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.LoginAccounts.AddRange(existingLoginAccount1, existingLoginAccount2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "accountRecoveries", + id = existingLoginAccount2.Recovery.StringId + } + }; + + string route = $"/loginAccounts/{existingLoginAccount1.StringId}/relationships/recovery"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + LoginAccount loginAccountInDatabase1 = + await dbContext.LoginAccounts.Include(loginAccount => loginAccount.Recovery).FirstWithIdAsync(existingLoginAccount1.Id); + + loginAccountInDatabase1.Recovery.Should().NotBeNull(); + loginAccountInDatabase1.Recovery.Id.Should().Be(existingLoginAccount2.Recovery.Id); + + LoginAccount? loginAccountInDatabase2 = await dbContext.LoginAccounts.Include(loginAccount => loginAccount.Recovery) + .FirstWithIdOrDefaultAsync(existingLoginAccount2.Id); + + loginAccountInDatabase2.Should().BeNull(); + }); + + store.SqlCommands.Should().HaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."LastUsedAt", t1."UserName", t2."Id", t2."EmailAddress", t2."PhoneNumber" + FROM "LoginAccounts" AS t1 + INNER JOIN "AccountRecoveries" AS t2 ON t1."RecoveryId" = t2."Id" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingLoginAccount1.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + DELETE FROM "LoginAccounts" + WHERE "RecoveryId" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingLoginAccount2.Recovery.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "LoginAccounts" + SET "RecoveryId" = @p1 + WHERE "Id" = @p2 + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", existingLoginAccount2.Recovery.Id); + command.Parameters.Should().Contain("@p2", existingLoginAccount1.Id); + }); + } + + [Fact] + public async Task Can_replace_OneToOne_relationship_with_required_foreign_key_at_right_side() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + AccountRecovery existingAccountRecovery1 = _fakers.AccountRecovery.GenerateOne(); + existingAccountRecovery1.Account = _fakers.LoginAccount.GenerateOne(); + + AccountRecovery existingAccountRecovery2 = _fakers.AccountRecovery.GenerateOne(); + existingAccountRecovery2.Account = _fakers.LoginAccount.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AccountRecoveries.AddRange(existingAccountRecovery1, existingAccountRecovery2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "loginAccounts", + id = existingAccountRecovery2.Account.StringId + } + }; + + string route = $"/accountRecoveries/{existingAccountRecovery1.StringId}/relationships/account"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + AccountRecovery accountRecoveryInDatabase1 = + await dbContext.AccountRecoveries.Include(recovery => recovery.Account).FirstWithIdAsync(existingAccountRecovery1.Id); + + accountRecoveryInDatabase1.Account.Should().NotBeNull(); + accountRecoveryInDatabase1.Account.Id.Should().Be(existingAccountRecovery2.Account.Id); + + AccountRecovery accountRecoveryInDatabase2 = + await dbContext.AccountRecoveries.Include(recovery => recovery.Account).FirstWithIdAsync(existingAccountRecovery2.Id); + + accountRecoveryInDatabase2.Account.Should().BeNull(); + }); + + store.SqlCommands.Should().HaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."EmailAddress", t1."PhoneNumber", t2."Id", t2."LastUsedAt", t2."UserName" + FROM "AccountRecoveries" AS t1 + LEFT JOIN "LoginAccounts" AS t2 ON t1."Id" = t2."RecoveryId" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingAccountRecovery1.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + DELETE FROM "LoginAccounts" + WHERE "Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingAccountRecovery1.Account.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "LoginAccounts" + SET "RecoveryId" = @p1 + WHERE "Id" = @p2 + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", existingAccountRecovery1.Id); + command.Parameters.Should().Contain("@p2", existingAccountRecovery2.Account.Id); + }); + } + + [Fact] + public async Task Can_replace_ManyToOne_relationship_with_nullable_foreign_key() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem existingTodoItem1 = _fakers.TodoItem.GenerateOne(); + existingTodoItem1.Owner = _fakers.Person.GenerateOne(); + existingTodoItem1.Assignee = _fakers.Person.GenerateOne(); + + TodoItem existingTodoItem2 = _fakers.TodoItem.GenerateOne(); + existingTodoItem2.Owner = _fakers.Person.GenerateOne(); + existingTodoItem2.Assignee = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.AddRange(existingTodoItem1, existingTodoItem2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "people", + id = existingTodoItem2.Assignee.StringId + } + }; + + string route = $"/todoItems/{existingTodoItem1.StringId}/relationships/assignee"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + TodoItem todoItemInDatabase1 = await dbContext.TodoItems.Include(todoItem => todoItem.Assignee).FirstWithIdAsync(existingTodoItem1.Id); + + todoItemInDatabase1.Assignee.Should().NotBeNull(); + todoItemInDatabase1.Assignee.Id.Should().Be(existingTodoItem2.Assignee.Id); + }); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority", t2."Id", t2."FirstName", t2."LastName" + FROM "TodoItems" AS t1 + LEFT JOIN "People" AS t2 ON t1."AssigneeId" = t2."Id" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingTodoItem1.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "TodoItems" + SET "AssigneeId" = @p1 + WHERE "Id" = @p2 + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", existingTodoItem2.Assignee.Id); + command.Parameters.Should().Contain("@p2", existingTodoItem1.Id); + }); + } + + [Fact] + public async Task Can_replace_ManyToOne_relationship_with_required_foreign_key() + { + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem existingTodoItem1 = _fakers.TodoItem.GenerateOne(); + existingTodoItem1.Owner = _fakers.Person.GenerateOne(); + + TodoItem existingTodoItem2 = _fakers.TodoItem.GenerateOne(); + existingTodoItem2.Owner = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.AddRange(existingTodoItem1, existingTodoItem2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "people", + id = existingTodoItem2.Owner.StringId + } + }; + + string route = $"/todoItems/{existingTodoItem1.StringId}/relationships/owner"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + TodoItem todoItemInDatabase1 = await dbContext.TodoItems.Include(todoItem => todoItem.Owner).FirstWithIdAsync(existingTodoItem1.Id); + + todoItemInDatabase1.Owner.Should().NotBeNull(); + todoItemInDatabase1.Owner.Id.Should().Be(existingTodoItem2.Owner.Id); + }); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority", t2."Id", t2."FirstName", t2."LastName" + FROM "TodoItems" AS t1 + INNER JOIN "People" AS t2 ON t1."OwnerId" = t2."Id" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingTodoItem1.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "TodoItems" + SET "OwnerId" = @p1 + WHERE "Id" = @p2 + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", existingTodoItem2.Owner.Id); + command.Parameters.Should().Contain("@p2", existingTodoItem1.Id); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Resources/CreateResourceTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Resources/CreateResourceTests.cs new file mode 100644 index 0000000000..ce74e99839 --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Resources/CreateResourceTests.cs @@ -0,0 +1,762 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.ReadWrite.Resources; + +public sealed class CreateResourceTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public CreateResourceTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_create_resource() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem newTodoItem = _fakers.TodoItem.GenerateOne(); + + Person existingPerson = _fakers.Person.GenerateOne(); + Tag existingTag = _fakers.Tag.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingPerson, existingTag); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "todoItems", + attributes = new + { + description = newTodoItem.Description, + priority = newTodoItem.Priority, + durationInHours = newTodoItem.DurationInHours + }, + relationships = new + { + owner = new + { + data = new + { + type = "people", + id = existingPerson.StringId + } + }, + assignee = new + { + data = new + { + type = "people", + id = existingPerson.StringId + } + }, + tags = new + { + data = new[] + { + new + { + type = "tags", + id = existingTag.StringId + } + } + } + } + } + }; + + const string route = "/todoItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("todoItems"); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("description").WhoseValue.Should().Be(newTodoItem.Description); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("priority").WhoseValue.Should().Be(newTodoItem.Priority); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("durationInHours").WhoseValue.Should().Be(newTodoItem.DurationInHours); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("createdAt").WhoseValue.Should().Be(DapperTestContext.FrozenTime); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("modifiedAt").WhoseValue.Should().BeNull(); + + responseDocument.Data.SingleValue.Relationships.Should().OnlyContainKeys("owner", "assignee", "tags"); + + long newTodoItemId = long.Parse(responseDocument.Data.SingleValue.Id.Should().NotBeNull().And.Subject); + httpResponse.Headers.Location.Should().Be($"/todoItems/{newTodoItemId}"); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + TodoItem todoItemInDatabase = await dbContext.TodoItems + .Include(todoItem => todoItem.Owner) + .Include(todoItem => todoItem.Assignee) + .Include(todoItem => todoItem.Tags) + .FirstWithIdAsync(newTodoItemId); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + todoItemInDatabase.Description.Should().Be(newTodoItem.Description); + todoItemInDatabase.Priority.Should().Be(newTodoItem.Priority); + todoItemInDatabase.DurationInHours.Should().Be(newTodoItem.DurationInHours); + todoItemInDatabase.CreatedAt.Should().Be(DapperTestContext.FrozenTime); + todoItemInDatabase.LastModifiedAt.Should().BeNull(); + + todoItemInDatabase.Owner.Should().NotBeNull(); + todoItemInDatabase.Owner.Id.Should().Be(existingPerson.Id); + todoItemInDatabase.Assignee.Should().NotBeNull(); + todoItemInDatabase.Assignee.Id.Should().Be(existingPerson.Id); + todoItemInDatabase.Tags.Should().HaveCount(1); + todoItemInDatabase.Tags.ElementAt(0).Id.Should().Be(existingTag.Id); + }); + + store.SqlCommands.Should().HaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + INSERT INTO "TodoItems" ("Description", "Priority", "DurationInHours", "CreatedAt", "LastModifiedAt", "OwnerId", "AssigneeId") + VALUES (@p1, @p2, @p3, @p4, @p5, @p6, @p7) + RETURNING "Id" + """)); + + command.Parameters.Should().HaveCount(7); + command.Parameters.Should().Contain("@p1", newTodoItem.Description); + command.Parameters.Should().Contain("@p2", newTodoItem.Priority); + command.Parameters.Should().Contain("@p3", newTodoItem.DurationInHours); + command.Parameters.Should().Contain("@p4", DapperTestContext.FrozenTime); + command.Parameters.Should().Contain("@p5", null); + command.Parameters.Should().Contain("@p6", existingPerson.Id); + command.Parameters.Should().Contain("@p7", existingPerson.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "Tags" + SET "TodoItemId" = @p1 + WHERE "Id" = @p2 + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", newTodoItemId); + command.Parameters.Should().Contain("@p2", existingTag.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", newTodoItemId); + }); + } + + [Fact] + public async Task Can_create_resource_with_only_required_fields() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem newTodoItem = _fakers.TodoItem.GenerateOne(); + + Person existingPerson = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.People.Add(existingPerson); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "todoItems", + attributes = new + { + description = newTodoItem.Description, + priority = newTodoItem.Priority + }, + relationships = new + { + owner = new + { + data = new + { + type = "people", + id = existingPerson.StringId + } + } + } + } + }; + + const string route = "/todoItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("todoItems"); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("description").WhoseValue.Should().Be(newTodoItem.Description); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("priority").WhoseValue.Should().Be(newTodoItem.Priority); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("durationInHours").WhoseValue.Should().BeNull(); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("createdAt").WhoseValue.Should().Be(DapperTestContext.FrozenTime); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("modifiedAt").WhoseValue.Should().BeNull(); + responseDocument.Data.SingleValue.Relationships.Should().OnlyContainKeys("owner", "assignee", "tags"); + + long newTodoItemId = long.Parse(responseDocument.Data.SingleValue.Id.Should().NotBeNull().And.Subject); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + TodoItem todoItemInDatabase = await dbContext.TodoItems + .Include(todoItem => todoItem.Owner) + .Include(todoItem => todoItem.Assignee) + .Include(todoItem => todoItem.Tags) + .FirstWithIdAsync(newTodoItemId); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + todoItemInDatabase.Description.Should().Be(newTodoItem.Description); + todoItemInDatabase.Priority.Should().Be(newTodoItem.Priority); + todoItemInDatabase.DurationInHours.Should().BeNull(); + todoItemInDatabase.CreatedAt.Should().Be(DapperTestContext.FrozenTime); + todoItemInDatabase.LastModifiedAt.Should().BeNull(); + + todoItemInDatabase.Owner.Should().NotBeNull(); + todoItemInDatabase.Owner.Id.Should().Be(existingPerson.Id); + todoItemInDatabase.Assignee.Should().BeNull(); + todoItemInDatabase.Tags.Should().BeEmpty(); + }); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + INSERT INTO "TodoItems" ("Description", "Priority", "DurationInHours", "CreatedAt", "LastModifiedAt", "OwnerId", "AssigneeId") + VALUES (@p1, @p2, @p3, @p4, @p5, @p6, @p7) + RETURNING "Id" + """)); + + command.Parameters.Should().HaveCount(7); + command.Parameters.Should().Contain("@p1", newTodoItem.Description); + command.Parameters.Should().Contain("@p2", newTodoItem.Priority); + command.Parameters.Should().Contain("@p3", null); + command.Parameters.Should().Contain("@p4", DapperTestContext.FrozenTime); + command.Parameters.Should().Contain("@p5", null); + command.Parameters.Should().Contain("@p6", existingPerson.Id); + command.Parameters.Should().Contain("@p7", null); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", newTodoItemId); + }); + } + + [Fact] + public async Task Cannot_create_resource_without_required_fields() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + var requestBody = new + { + data = new + { + type = "todoItems", + attributes = new + { + } + } + }; + + const string route = "/todoItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(3); + + ErrorObject error1 = responseDocument.Errors[0]; + error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error1.Title.Should().Be("Input validation failed."); + error1.Detail.Should().Be("The Owner field is required."); + error1.Source.Should().NotBeNull(); + error1.Source.Pointer.Should().Be("/data/relationships/owner/data"); + + ErrorObject error2 = responseDocument.Errors[1]; + error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error2.Title.Should().Be("Input validation failed."); + error2.Detail.Should().Be("The Priority field is required."); + error2.Source.Should().NotBeNull(); + error2.Source.Pointer.Should().Be("/data/attributes/priority"); + + ErrorObject error3 = responseDocument.Errors[2]; + error3.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error3.Title.Should().Be("Input validation failed."); + error3.Detail.Should().Be("The Description field is required."); + error3.Source.Should().NotBeNull(); + error3.Source.Pointer.Should().Be("/data/attributes/description"); + + store.SqlCommands.Should().BeEmpty(); + } + + [Fact] + public async Task Can_create_resource_with_unmapped_property() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + AccountRecovery existingAccountRecovery = _fakers.AccountRecovery.GenerateOne(); + Person existingPerson = _fakers.Person.GenerateOne(); + + string newUserName = _fakers.LoginAccount.GenerateOne().UserName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingAccountRecovery, existingPerson); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "loginAccounts", + attributes = new + { + userName = newUserName + }, + relationships = new + { + recovery = new + { + data = new + { + type = "accountRecoveries", + id = existingAccountRecovery.StringId + } + }, + person = new + { + data = new + { + type = "people", + id = existingPerson.StringId + } + } + } + } + }; + + const string route = "/loginAccounts"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("loginAccounts"); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("userName").WhoseValue.Should().Be(newUserName); + responseDocument.Data.SingleValue.Attributes.Should().NotContainKey("lastUsedAt"); + responseDocument.Data.SingleValue.Relationships.Should().OnlyContainKeys("recovery", "person"); + + long newLoginAccountId = long.Parse(responseDocument.Data.SingleValue.Id.Should().NotBeNull().And.Subject); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + LoginAccount loginAccountInDatabase = await dbContext.LoginAccounts + .Include(todoItem => todoItem.Recovery) + .Include(todoItem => todoItem.Person) + .FirstWithIdAsync(newLoginAccountId); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + loginAccountInDatabase.UserName.Should().Be(newUserName); + loginAccountInDatabase.LastUsedAt.Should().BeNull(); + + loginAccountInDatabase.Recovery.Should().NotBeNull(); + loginAccountInDatabase.Recovery.Id.Should().Be(existingAccountRecovery.Id); + loginAccountInDatabase.Person.Should().NotBeNull(); + loginAccountInDatabase.Person.Id.Should().Be(existingPerson.Id); + }); + + store.SqlCommands.Should().HaveCount(4); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + DELETE FROM "LoginAccounts" + WHERE "RecoveryId" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingAccountRecovery.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + INSERT INTO "LoginAccounts" ("UserName", "LastUsedAt", "RecoveryId") + VALUES (@p1, @p2, @p3) + RETURNING "Id" + """)); + + command.Parameters.Should().HaveCount(3); + command.Parameters.Should().Contain("@p1", newUserName); + command.Parameters.Should().Contain("@p2", null); + command.Parameters.Should().Contain("@p3", existingAccountRecovery.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "People" + SET "AccountId" = @p1 + WHERE "Id" = @p2 + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", newLoginAccountId); + command.Parameters.Should().Contain("@p2", existingPerson.Id); + }); + + store.SqlCommands[3].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."LastUsedAt", t1."UserName" + FROM "LoginAccounts" AS t1 + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", newLoginAccountId); + }); + } + + [Fact] + public async Task Can_create_resource_with_calculated_attribute() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person newPerson = _fakers.Person.GenerateOne(); + + var requestBody = new + { + data = new + { + type = "people", + attributes = new + { + firstName = newPerson.FirstName, + lastName = newPerson.LastName + } + } + }; + + const string route = "/people"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("people"); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("firstName").WhoseValue.Should().Be(newPerson.FirstName); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("lastName").WhoseValue.Should().Be(newPerson.LastName); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("displayName").WhoseValue.Should().Be(newPerson.DisplayName); + responseDocument.Data.SingleValue.Relationships.Should().OnlyContainKeys("account", "ownedTodoItems", "assignedTodoItems"); + + long newPersonId = long.Parse(responseDocument.Data.SingleValue.Id.Should().NotBeNull().And.Subject); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Person personInDatabase = await dbContext.People.FirstWithIdAsync(newPersonId); + + personInDatabase.FirstName.Should().Be(newPerson.FirstName); + personInDatabase.LastName.Should().Be(newPerson.LastName); + personInDatabase.DisplayName.Should().Be(newPerson.DisplayName); + }); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + INSERT INTO "People" ("FirstName", "LastName", "AccountId") + VALUES (@p1, @p2, @p3) + RETURNING "Id" + """)); + + command.Parameters.Should().HaveCount(3); + command.Parameters.Should().Contain("@p1", newPerson.FirstName); + command.Parameters.Should().Contain("@p2", newPerson.LastName); + command.Parameters.Should().Contain("@p3", null); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName" + FROM "People" AS t1 + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", newPersonId); + }); + } + + [Fact] + public async Task Can_create_resource_with_client_generated_ID() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Tag existingTag = _fakers.Tag.GenerateOne(); + + RgbColor newColor = _fakers.RgbColor.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.Tags.Add(existingTag); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = newColor.StringId, + relationships = new + { + tag = new + { + data = new + { + type = "tags", + id = existingTag.StringId + } + } + } + } + }; + + const string route = "/rgbColors/"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + RgbColor colorInDatabase = await dbContext.RgbColors.Include(rgbColor => rgbColor.Tag).FirstWithIdAsync(newColor.Id); + + colorInDatabase.Red.Should().Be(newColor.Red); + colorInDatabase.Green.Should().Be(newColor.Green); + colorInDatabase.Blue.Should().Be(newColor.Blue); + + colorInDatabase.Tag.Should().NotBeNull(); + colorInDatabase.Tag.Id.Should().Be(existingTag.Id); + }); + + store.SqlCommands.Should().HaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + DELETE FROM "RgbColors" + WHERE "TagId" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingTag.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + INSERT INTO "RgbColors" ("Id", "TagId") + VALUES (@p1, @p2) + RETURNING "Id" + """, true)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", newColor.Id); + command.Parameters.Should().Contain("@p2", existingTag.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id" + FROM "RgbColors" AS t1 + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", newColor.Id); + }); + } + + [Fact] + public async Task Cannot_create_resource_for_existing_client_generated_ID() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + RgbColor existingColor = _fakers.RgbColor.GenerateOne(); + existingColor.Tag = _fakers.Tag.GenerateOne(); + + Tag existingTag = _fakers.Tag.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.AddInRange(existingColor, existingTag); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = existingColor.StringId, + relationships = new + { + tag = new + { + data = new + { + type = "tags", + id = existingTag.StringId + } + } + } + } + }; + + const string route = "/rgbColors"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Conflict); + error.Title.Should().Be("Another resource with the specified ID already exists."); + error.Detail.Should().Be($"Another resource of type 'rgbColors' with ID '{existingColor.StringId}' already exists."); + error.Source.Should().BeNull(); + + store.SqlCommands.Should().HaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + DELETE FROM "RgbColors" + WHERE "TagId" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingTag.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + INSERT INTO "RgbColors" ("Id", "TagId") + VALUES (@p1, @p2) + RETURNING "Id" + """, true)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", existingColor.Id); + command.Parameters.Should().Contain("@p2", existingTag.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id" + FROM "RgbColors" AS t1 + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingColor.Id); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Resources/DeleteResourceTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Resources/DeleteResourceTests.cs new file mode 100644 index 0000000000..566c546c73 --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Resources/DeleteResourceTests.cs @@ -0,0 +1,129 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.ReadWrite.Resources; + +public sealed class DeleteResourceTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public DeleteResourceTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_delete_resource() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem existingTodoItem = _fakers.TodoItem.GenerateOne(); + existingTodoItem.Owner = _fakers.Person.GenerateOne(); + existingTodoItem.Tags = _fakers.Tag.GenerateSet(1); + existingTodoItem.Tags.ElementAt(0).Color = _fakers.RgbColor.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.Add(existingTodoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{existingTodoItem.StringId}"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + TodoItem? todoItemInDatabase = await dbContext.TodoItems.FirstWithIdOrDefaultAsync(existingTodoItem.Id); + + todoItemInDatabase.Should().BeNull(); + + List tags = await dbContext.Tags.Where(tag => tag.TodoItem == null).ToListAsync(); + + tags.Should().HaveCount(1); + }); + + store.SqlCommands.Should().HaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + DELETE FROM "TodoItems" + WHERE "Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingTodoItem.Id); + }); + } + + [Fact] + public async Task Cannot_delete_unknown_resource() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + const long unknownTodoItemId = Unknown.TypedId.Int64; + + string route = $"/todoItems/{unknownTodoItemId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'todoItems' with ID '{unknownTodoItemId}' does not exist."); + error.Source.Should().BeNull(); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + DELETE FROM "TodoItems" + WHERE "Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", unknownTodoItemId); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id" + FROM "TodoItems" AS t1 + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", unknownTodoItemId); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Resources/FetchResourceTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Resources/FetchResourceTests.cs new file mode 100644 index 0000000000..52bb378b2a --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Resources/FetchResourceTests.cs @@ -0,0 +1,349 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.ReadWrite.Resources; + +public sealed class FetchResourceTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public FetchResourceTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_get_primary_resources() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + List todoItems = _fakers.TodoItem.GenerateList(2); + todoItems.ForEach(todoItem => todoItem.Owner = _fakers.Person.GenerateOne()); + + todoItems[0].Priority = TodoItemPriority.Low; + todoItems[1].Priority = TodoItemPriority.High; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.AddRange(todoItems); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/todoItems"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("todoItems")); + + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItems[1].StringId); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("description").WhoseValue.Should().Be(todoItems[1].Description); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("priority").WhoseValue.Should().Be(todoItems[1].Priority); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("durationInHours").WhoseValue.Should().Be(todoItems[1].DurationInHours); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("createdAt").WhoseValue.Should().Be(todoItems[1].CreatedAt); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("modifiedAt").WhoseValue.Should().Be(todoItems[1].LastModifiedAt); + responseDocument.Data.ManyValue[0].Relationships.Should().OnlyContainKeys("owner", "assignee", "tags"); + + responseDocument.Data.ManyValue[1].Id.Should().Be(todoItems[0].StringId); + responseDocument.Data.ManyValue[1].Attributes.Should().ContainKey("description").WhoseValue.Should().Be(todoItems[0].Description); + responseDocument.Data.ManyValue[1].Attributes.Should().ContainKey("priority").WhoseValue.Should().Be(todoItems[0].Priority); + responseDocument.Data.ManyValue[1].Attributes.Should().ContainKey("durationInHours").WhoseValue.Should().Be(todoItems[0].DurationInHours); + responseDocument.Data.ManyValue[1].Attributes.Should().ContainKey("createdAt").WhoseValue.Should().Be(todoItems[0].CreatedAt); + responseDocument.Data.ManyValue[1].Attributes.Should().ContainKey("modifiedAt").WhoseValue.Should().Be(todoItems[0].LastModifiedAt); + responseDocument.Data.ManyValue[1].Relationships.Should().OnlyContainKeys("owner", "assignee", "tags"); + + responseDocument.Meta.Should().ContainTotal(2); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + """)); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + ORDER BY t1."Priority", t1."LastModifiedAt" DESC + """)); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_get_primary_resource_by_ID() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.GenerateOne(); + todoItem.Owner = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{todoItem.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("todoItems"); + responseDocument.Data.SingleValue.Id.Should().Be(todoItem.StringId); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("description").WhoseValue.Should().Be(todoItem.Description); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("priority").WhoseValue.Should().Be(todoItem.Priority); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("durationInHours").WhoseValue.Should().Be(todoItem.DurationInHours); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("createdAt").WhoseValue.Should().Be(todoItem.CreatedAt); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("modifiedAt").WhoseValue.Should().Be(todoItem.LastModifiedAt); + responseDocument.Data.SingleValue.Relationships.Should().OnlyContainKeys("owner", "assignee", "tags"); + + responseDocument.Meta.Should().BeNull(); + + store.SqlCommands.Should().HaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + } + + [Fact] + public async Task Cannot_get_unknown_primary_resource_by_ID() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + const long unknownTodoItemId = Unknown.TypedId.Int64; + + string route = $"/todoItems/{unknownTodoItemId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotFound); + error.Title.Should().Be("The requested resource does not exist."); + error.Detail.Should().Be($"Resource of type 'todoItems' with ID '{unknownTodoItemId}' does not exist."); + error.Source.Should().BeNull(); + + store.SqlCommands.Should().HaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", unknownTodoItemId); + }); + } + + [Fact] + public async Task Can_get_secondary_ToMany_resources() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.GenerateOne(); + todoItem.Owner = _fakers.Person.GenerateOne(); + todoItem.Tags = _fakers.Tag.GenerateSet(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{todoItem.StringId}/tags"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Data.ManyValue.Should().AllSatisfy(resource => resource.Type.Should().Be("tags")); + + responseDocument.Data.ManyValue[0].Id.Should().Be(todoItem.Tags.ElementAt(0).StringId); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("name").WhoseValue.Should().Be(todoItem.Tags.ElementAt(0).Name); + responseDocument.Data.ManyValue[0].Relationships.Should().OnlyContainKeys("todoItem", "color"); + + responseDocument.Data.ManyValue[1].Id.Should().Be(todoItem.Tags.ElementAt(1).StringId); + responseDocument.Data.ManyValue[1].Attributes.Should().ContainKey("name").WhoseValue.Should().Be(todoItem.Tags.ElementAt(1).Name); + responseDocument.Data.ManyValue[1].Relationships.Should().OnlyContainKeys("todoItem", "color"); + + responseDocument.Meta.Should().ContainTotal(2); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "Tags" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."TodoItemId" = t2."Id" + WHERE t2."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t2."Id", t2."Name" + FROM "TodoItems" AS t1 + LEFT JOIN "Tags" AS t2 ON t1."Id" = t2."TodoItemId" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + } + + [Fact] + public async Task Can_get_secondary_ToOne_resource() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.GenerateOne(); + todoItem.Owner = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{todoItem.StringId}/owner"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("people"); + responseDocument.Data.SingleValue.Id.Should().Be(todoItem.Owner.StringId); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("firstName").WhoseValue.Should().Be(todoItem.Owner.FirstName); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("lastName").WhoseValue.Should().Be(todoItem.Owner.LastName); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("displayName").WhoseValue.Should().Be(todoItem.Owner.DisplayName); + responseDocument.Data.SingleValue.Relationships.Should().OnlyContainKeys("account", "ownedTodoItems", "assignedTodoItems"); + + responseDocument.Meta.Should().BeNull(); + + store.SqlCommands.Should().HaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t2."Id", t2."FirstName", t2."LastName" + FROM "TodoItems" AS t1 + INNER JOIN "People" AS t2 ON t1."OwnerId" = t2."Id" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + } + + [Fact] + public async Task Can_get_empty_secondary_ToOne_resource() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.GenerateOne(); + todoItem.Owner = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/todoItems/{todoItem.StringId}/assignee"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.Should().BeNull(); + + responseDocument.Meta.Should().BeNull(); + + store.SqlCommands.Should().HaveCount(1); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t2."Id", t2."FirstName", t2."LastName" + FROM "TodoItems" AS t1 + LEFT JOIN "People" AS t2 ON t1."AssigneeId" = t2."Id" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", todoItem.Id); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/ReadWrite/Resources/UpdateResourceTests.cs b/test/DapperTests/IntegrationTests/ReadWrite/Resources/UpdateResourceTests.cs new file mode 100644 index 0000000000..dc8bccf5ee --- /dev/null +++ b/test/DapperTests/IntegrationTests/ReadWrite/Resources/UpdateResourceTests.cs @@ -0,0 +1,415 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.ReadWrite.Resources; + +public sealed class UpdateResourceTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public UpdateResourceTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Can_update_resource_without_attributes_or_relationships() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Tag existingTag = _fakers.Tag.GenerateOne(); + existingTag.Color = _fakers.RgbColor.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.Tags.Add(existingTag); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "tags", + id = existingTag.StringId, + attributes = new + { + }, + relationships = new + { + } + } + }; + + string route = $"/tags/{existingTag.StringId}"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Tag tagInDatabase = await dbContext.Tags.Include(tag => tag.Color).FirstWithIdAsync(existingTag.Id); + + tagInDatabase.Name.Should().Be(existingTag.Name); + tagInDatabase.Color.Should().NotBeNull(); + tagInDatabase.Color.Id.Should().Be(existingTag.Color.Id); + }); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."Name" + FROM "Tags" AS t1 + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingTag.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."Name" + FROM "Tags" AS t1 + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingTag.Id); + }); + } + + [Fact] + public async Task Can_partially_update_resource_attributes() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem existingTodoItem = _fakers.TodoItem.GenerateOne(); + existingTodoItem.Owner = _fakers.Person.GenerateOne(); + existingTodoItem.Assignee = _fakers.Person.GenerateOne(); + existingTodoItem.Tags = _fakers.Tag.GenerateSet(1); + + string newDescription = _fakers.TodoItem.GenerateOne().Description; + long newDurationInHours = _fakers.TodoItem.GenerateOne().DurationInHours!.Value; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.TodoItems.Add(existingTodoItem); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "todoItems", + id = existingTodoItem.StringId, + attributes = new + { + description = newDescription, + durationInHours = newDurationInHours + } + } + }; + + string route = $"/todoItems/{existingTodoItem.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("todoItems"); + responseDocument.Data.SingleValue.Id.Should().Be(existingTodoItem.StringId); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("description").WhoseValue.Should().Be(newDescription); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("priority").WhoseValue.Should().Be(existingTodoItem.Priority); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("durationInHours").WhoseValue.Should().Be(newDurationInHours); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("createdAt").WhoseValue.Should().Be(existingTodoItem.CreatedAt); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("modifiedAt").WhoseValue.Should().Be(DapperTestContext.FrozenTime); + responseDocument.Data.SingleValue.Relationships.Should().OnlyContainKeys("owner", "assignee", "tags"); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + TodoItem todoItemInDatabase = await dbContext.TodoItems + .Include(todoItem => todoItem.Owner) + .Include(todoItem => todoItem.Assignee) + .Include(todoItem => todoItem.Tags) + .FirstWithIdAsync(existingTodoItem.Id); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + todoItemInDatabase.Description.Should().Be(newDescription); + todoItemInDatabase.Priority.Should().Be(existingTodoItem.Priority); + todoItemInDatabase.DurationInHours.Should().Be(newDurationInHours); + todoItemInDatabase.CreatedAt.Should().Be(existingTodoItem.CreatedAt); + todoItemInDatabase.LastModifiedAt.Should().Be(DapperTestContext.FrozenTime); + + todoItemInDatabase.Owner.Should().NotBeNull(); + todoItemInDatabase.Owner.Id.Should().Be(existingTodoItem.Owner.Id); + todoItemInDatabase.Assignee.Should().NotBeNull(); + todoItemInDatabase.Assignee.Id.Should().Be(existingTodoItem.Assignee.Id); + todoItemInDatabase.Tags.Should().HaveCount(1); + todoItemInDatabase.Tags.ElementAt(0).Id.Should().Be(existingTodoItem.Tags.ElementAt(0).Id); + }); + + store.SqlCommands.Should().HaveCount(3); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingTodoItem.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "TodoItems" + SET "Description" = @p1, "DurationInHours" = @p2, "LastModifiedAt" = @p3 + WHERE "Id" = @p4 + """)); + + command.Parameters.Should().HaveCount(4); + command.Parameters.Should().Contain("@p1", newDescription); + command.Parameters.Should().Contain("@p2", newDurationInHours); + command.Parameters.Should().Contain("@p3", DapperTestContext.FrozenTime); + command.Parameters.Should().Contain("@p4", existingTodoItem.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingTodoItem.Id); + }); + } + + [Fact] + public async Task Can_completely_update_resource() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem existingTodoItem = _fakers.TodoItem.GenerateOne(); + existingTodoItem.Owner = _fakers.Person.GenerateOne(); + existingTodoItem.Assignee = _fakers.Person.GenerateOne(); + existingTodoItem.Tags = _fakers.Tag.GenerateSet(2); + + TodoItem newTodoItem = _fakers.TodoItem.GenerateOne(); + + Tag existingTag = _fakers.Tag.GenerateOne(); + Person existingPerson1 = _fakers.Person.GenerateOne(); + Person existingPerson2 = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingTodoItem, existingTag, existingPerson1, existingPerson2); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "todoItems", + id = existingTodoItem.StringId, + attributes = new + { + description = newTodoItem.Description, + priority = newTodoItem.Priority, + durationInHours = newTodoItem.DurationInHours + }, + relationships = new + { + owner = new + { + data = new + { + type = "people", + id = existingPerson1.StringId + } + }, + assignee = new + { + data = new + { + type = "people", + id = existingPerson2.StringId + } + }, + tags = new + { + data = new[] + { + new + { + type = "tags", + id = existingTag.StringId + } + } + } + } + } + }; + + string route = $"/todoItems/{existingTodoItem.StringId}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("todoItems"); + responseDocument.Data.SingleValue.Id.Should().Be(existingTodoItem.StringId); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("description").WhoseValue.Should().Be(newTodoItem.Description); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("priority").WhoseValue.Should().Be(newTodoItem.Priority); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("durationInHours").WhoseValue.Should().Be(newTodoItem.DurationInHours); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("createdAt").WhoseValue.Should().Be(existingTodoItem.CreatedAt); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("modifiedAt").WhoseValue.Should().Be(DapperTestContext.FrozenTime); + responseDocument.Data.SingleValue.Relationships.Should().OnlyContainKeys("owner", "assignee", "tags"); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + TodoItem todoItemInDatabase = await dbContext.TodoItems + .Include(todoItem => todoItem.Owner) + .Include(todoItem => todoItem.Assignee) + .Include(todoItem => todoItem.Tags) + .FirstWithIdAsync(existingTodoItem.Id); + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore + + todoItemInDatabase.Description.Should().Be(newTodoItem.Description); + todoItemInDatabase.Priority.Should().Be(newTodoItem.Priority); + todoItemInDatabase.DurationInHours.Should().Be(newTodoItem.DurationInHours); + todoItemInDatabase.CreatedAt.Should().Be(existingTodoItem.CreatedAt); + todoItemInDatabase.LastModifiedAt.Should().Be(DapperTestContext.FrozenTime); + + todoItemInDatabase.Owner.Should().NotBeNull(); + todoItemInDatabase.Owner.Id.Should().Be(existingPerson1.Id); + todoItemInDatabase.Assignee.Should().NotBeNull(); + todoItemInDatabase.Assignee.Id.Should().Be(existingPerson2.Id); + todoItemInDatabase.Tags.Should().HaveCount(1); + todoItemInDatabase.Tags.ElementAt(0).Id.Should().Be(existingTag.Id); + }); + + store.SqlCommands.Should().HaveCount(5); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority", t2."Id", t2."FirstName", t2."LastName", t3."Id", t3."FirstName", t3."LastName", t4."Id", t4."Name" + FROM "TodoItems" AS t1 + LEFT JOIN "People" AS t2 ON t1."AssigneeId" = t2."Id" + INNER JOIN "People" AS t3 ON t1."OwnerId" = t3."Id" + LEFT JOIN "Tags" AS t4 ON t1."Id" = t4."TodoItemId" + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingTodoItem.Id); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "TodoItems" + SET "Description" = @p1, "Priority" = @p2, "DurationInHours" = @p3, "LastModifiedAt" = @p4, "OwnerId" = @p5, "AssigneeId" = @p6 + WHERE "Id" = @p7 + """)); + + command.Parameters.Should().HaveCount(7); + command.Parameters.Should().Contain("@p1", newTodoItem.Description); + command.Parameters.Should().Contain("@p2", newTodoItem.Priority); + command.Parameters.Should().Contain("@p3", newTodoItem.DurationInHours); + command.Parameters.Should().Contain("@p4", DapperTestContext.FrozenTime); + command.Parameters.Should().Contain("@p5", existingPerson1.Id); + command.Parameters.Should().Contain("@p6", existingPerson2.Id); + command.Parameters.Should().Contain("@p7", existingTodoItem.Id); + }); + + store.SqlCommands[2].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "Tags" + SET "TodoItemId" = @p1 + WHERE "Id" IN (@p2, @p3) + """)); + + command.Parameters.Should().HaveCount(3); + command.Parameters.Should().Contain("@p1", null); + command.Parameters.Should().Contain("@p2", existingTodoItem.Tags.ElementAt(0).Id); + command.Parameters.Should().Contain("@p3", existingTodoItem.Tags.ElementAt(1).Id); + }); + + store.SqlCommands[3].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + UPDATE "Tags" + SET "TodoItemId" = @p1 + WHERE "Id" = @p2 + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", existingTodoItem.Id); + command.Parameters.Should().Contain("@p2", existingTag.Id); + }); + + store.SqlCommands[4].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority" + FROM "TodoItems" AS t1 + WHERE t1."Id" = @p1 + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", existingTodoItem.Id); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/Sql/SubQueryInJoinTests.cs b/test/DapperTests/IntegrationTests/Sql/SubQueryInJoinTests.cs new file mode 100644 index 0000000000..9b6e62f39b --- /dev/null +++ b/test/DapperTests/IntegrationTests/Sql/SubQueryInJoinTests.cs @@ -0,0 +1,634 @@ +using System.Net; +using DapperExample.Models; +using DapperExample.Repositories; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; +using Xunit.Abstractions; + +namespace DapperTests.IntegrationTests.Sql; + +public sealed class SubQueryInJoinTests : IClassFixture +{ + private readonly DapperTestContext _testContext; + private readonly TestFakers _fakers = new(); + + public SubQueryInJoinTests(DapperTestContext testContext, ITestOutputHelper testOutputHelper) + { + testContext.SetTestOutputHelper(testOutputHelper); + _testContext = testContext; + } + + [Fact] + public async Task Join_with_table_on_ToOne_include() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?include=account"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "People" AS t1 + """)); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t2."Id", t2."LastUsedAt", t2."UserName" + FROM "People" AS t1 + LEFT JOIN "LoginAccounts" AS t2 ON t1."AccountId" = t2."Id" + """)); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Join_with_table_on_ToMany_include() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?include=ownedTodoItems"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "People" AS t1 + """)); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."Priority" + FROM "People" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."Id" = t2."OwnerId" + ORDER BY t2."Priority", t2."LastModifiedAt" DESC + """)); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Join_with_table_on_ToMany_include_with_nested_sort_on_attribute() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?include=ownedTodoItems&sort[ownedTodoItems]=description"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "People" AS t1 + """)); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."Priority" + FROM "People" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."Id" = t2."OwnerId" + ORDER BY t2."Description" + """)); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Join_with_table_on_ToMany_include_with_nested_sort_on_count() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?include=ownedTodoItems&sort[ownedTodoItems]=count(tags)"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "People" AS t1 + """)); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."Priority" + FROM "People" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."Id" = t2."OwnerId" + ORDER BY ( + SELECT COUNT(*) + FROM "Tags" AS t3 + WHERE t2."Id" = t3."TodoItemId" + ) + """)); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Join_with_tables_on_includes_with_nested_sorts() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?include=ownedTodoItems.tags&sort[ownedTodoItems]=count(tags)&sort[ownedTodoItems.tags]=-name"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "People" AS t1 + """)); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."Priority", t4."Id", t4."Name" + FROM "People" AS t1 + LEFT JOIN "TodoItems" AS t2 ON t1."Id" = t2."OwnerId" + LEFT JOIN "Tags" AS t4 ON t2."Id" = t4."TodoItemId" + ORDER BY ( + SELECT COUNT(*) + FROM "Tags" AS t3 + WHERE t2."Id" = t3."TodoItemId" + ), t4."Name" DESC + """)); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Join_with_tables_on_includes_with_nested_sorts_on_counts() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + TodoItem todoItem = _fakers.TodoItem.GenerateOne(); + todoItem.Owner = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.TodoItems.Add(todoItem); + await dbContext.SaveChangesAsync(); + }); + + const string route = + "/todoItems?include=owner.ownedTodoItems.tags,owner.assignedTodoItems.tags&sort[owner.ownedTodoItems]=count(tags)&sort[owner.assignedTodoItems]=count(tags)"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "TodoItems" AS t1 + """)); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."CreatedAt", t1."Description", t1."DurationInHours", t1."LastModifiedAt", t1."Priority", t2."Id", t2."FirstName", t2."LastName", t3."Id", t3."CreatedAt", t3."Description", t3."DurationInHours", t3."LastModifiedAt", t3."Priority", t5."Id", t5."Name", t6."Id", t6."CreatedAt", t6."Description", t6."DurationInHours", t6."LastModifiedAt", t6."Priority", t8."Id", t8."Name" + FROM "TodoItems" AS t1 + INNER JOIN "People" AS t2 ON t1."OwnerId" = t2."Id" + LEFT JOIN "TodoItems" AS t3 ON t2."Id" = t3."AssigneeId" + LEFT JOIN "Tags" AS t5 ON t3."Id" = t5."TodoItemId" + LEFT JOIN "TodoItems" AS t6 ON t2."Id" = t6."OwnerId" + LEFT JOIN "Tags" AS t8 ON t6."Id" = t8."TodoItemId" + ORDER BY t1."Priority", t1."LastModifiedAt" DESC, ( + SELECT COUNT(*) + FROM "Tags" AS t4 + WHERE t3."Id" = t4."TodoItemId" + ), ( + SELECT COUNT(*) + FROM "Tags" AS t7 + WHERE t6."Id" = t7."TodoItemId" + ) + """)); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Join_with_sub_query_on_ToMany_include_with_nested_filter() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?include=ownedTodoItems&filter[ownedTodoItems]=equals(description,'X')"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "People" AS t1 + """)); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t3."Id", t3."CreatedAt", t3."Description", t3."DurationInHours", t3."LastModifiedAt", t3."Priority" + FROM "People" AS t1 + LEFT JOIN ( + SELECT t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."OwnerId", t2."Priority" + FROM "TodoItems" AS t2 + WHERE t2."Description" = @p1 + ) AS t3 ON t1."Id" = t3."OwnerId" + ORDER BY t3."Priority", t3."LastModifiedAt" DESC + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", "X"); + }); + } + + [Fact] + public async Task Join_with_sub_query_on_ToMany_include_with_nested_filter_on_has() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?include=ownedTodoItems&filter[ownedTodoItems]=has(tags)"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "People" AS t1 + """)); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t4."Id", t4."CreatedAt", t4."Description", t4."DurationInHours", t4."LastModifiedAt", t4."Priority" + FROM "People" AS t1 + LEFT JOIN ( + SELECT t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."OwnerId", t2."Priority" + FROM "TodoItems" AS t2 + WHERE EXISTS ( + SELECT 1 + FROM "Tags" AS t3 + WHERE t2."Id" = t3."TodoItemId" + ) + ) AS t4 ON t1."Id" = t4."OwnerId" + ORDER BY t4."Priority", t4."LastModifiedAt" DESC + """)); + + command.Parameters.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Join_with_sub_query_on_ToMany_include_with_nested_filter_on_count() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/people?include=ownedTodoItems&filter[ownedTodoItems]=greaterThan(count(tags),'0')"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "People" AS t1 + """)); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t4."Id", t4."CreatedAt", t4."Description", t4."DurationInHours", t4."LastModifiedAt", t4."Priority" + FROM "People" AS t1 + LEFT JOIN ( + SELECT t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."OwnerId", t2."Priority" + FROM "TodoItems" AS t2 + WHERE ( + SELECT COUNT(*) + FROM "Tags" AS t3 + WHERE t2."Id" = t3."TodoItemId" + ) > @p1 + ) AS t4 ON t1."Id" = t4."OwnerId" + ORDER BY t4."Priority", t4."LastModifiedAt" DESC + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", 0); + }); + } + + [Fact] + public async Task Join_with_sub_query_on_includes_with_nested_filter_and_sorts() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = + "/people?include=ownedTodoItems.tags&filter[ownedTodoItems]=equals(description,'X')&sort[ownedTodoItems]=count(tags)&sort[ownedTodoItems.tags]=-name"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "People" AS t1 + """)); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t5."Id", t5."CreatedAt", t5."Description", t5."DurationInHours", t5."LastModifiedAt", t5."Priority", t5.Id0 AS Id, t5."Name" + FROM "People" AS t1 + LEFT JOIN ( + SELECT t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."OwnerId", t2."Priority", t4."Id" AS Id0, t4."Name" + FROM "TodoItems" AS t2 + LEFT JOIN "Tags" AS t4 ON t2."Id" = t4."TodoItemId" + WHERE t2."Description" = @p1 + ) AS t5 ON t1."Id" = t5."OwnerId" + ORDER BY ( + SELECT COUNT(*) + FROM "Tags" AS t3 + WHERE t5."Id" = t3."TodoItemId" + ), t5."Name" DESC + """)); + + command.Parameters.Should().HaveCount(1); + command.Parameters.Should().Contain("@p1", "X"); + }); + } + + [Fact] + public async Task Join_with_nested_sub_queries_with_filters_and_sorts() + { + // Arrange + var store = _testContext.Factory.Services.GetRequiredService(); + store.Clear(); + + Person person = _fakers.Person.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await _testContext.ClearAllTablesAsync(dbContext); + dbContext.People.Add(person); + await dbContext.SaveChangesAsync(); + }); + + const string route = + "/people?include=ownedTodoItems.tags&filter[ownedTodoItems]=not(equals(description,'X'))&filter[ownedTodoItems.tags]=not(equals(name,'Y'))" + + "&sort[ownedTodoItems]=count(tags),assignee.lastName&sort[ownedTodoItems.tags]=name,-id"; + + // Act + (HttpResponseMessage httpResponse, string _) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + store.SqlCommands.Should().HaveCount(2); + + store.SqlCommands[0].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT COUNT(*) + FROM "People" AS t1 + """)); + + command.Parameters.Should().BeEmpty(); + }); + + store.SqlCommands[1].With(command => + { + command.Statement.Should().Be(_testContext.AdaptSql(""" + SELECT t1."Id", t1."FirstName", t1."LastName", t7."Id", t7."CreatedAt", t7."Description", t7."DurationInHours", t7."LastModifiedAt", t7."Priority", t7.Id00 AS Id, t7."Name" + FROM "People" AS t1 + LEFT JOIN ( + SELECT t2."Id", t2."CreatedAt", t2."Description", t2."DurationInHours", t2."LastModifiedAt", t2."OwnerId", t2."Priority", t4."LastName", t6."Id" AS Id00, t6."Name" + FROM "TodoItems" AS t2 + LEFT JOIN "People" AS t4 ON t2."AssigneeId" = t4."Id" + LEFT JOIN ( + SELECT t5."Id", t5."Name", t5."TodoItemId" + FROM "Tags" AS t5 + WHERE NOT (t5."Name" = @p2) + ) AS t6 ON t2."Id" = t6."TodoItemId" + WHERE NOT (t2."Description" = @p1) + ) AS t7 ON t1."Id" = t7."OwnerId" + ORDER BY ( + SELECT COUNT(*) + FROM "Tags" AS t3 + WHERE t7."Id" = t3."TodoItemId" + ), t7."LastName", t7."Name", t7.Id00 DESC + """)); + + command.Parameters.Should().HaveCount(2); + command.Parameters.Should().Contain("@p1", "X"); + command.Parameters.Should().Contain("@p2", "Y"); + }); + } +} diff --git a/test/DapperTests/IntegrationTests/SqlTextAdapter.cs b/test/DapperTests/IntegrationTests/SqlTextAdapter.cs new file mode 100644 index 0000000000..1d6d45555e --- /dev/null +++ b/test/DapperTests/IntegrationTests/SqlTextAdapter.cs @@ -0,0 +1,41 @@ +using System.Text.RegularExpressions; +using DapperExample; + +namespace DapperTests.IntegrationTests; + +internal sealed class SqlTextAdapter(DatabaseProvider databaseProvider) +{ + private const RegexOptions Options = RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.NonBacktracking; + + private static readonly Dictionary SqlServerReplacements = new() + { + [new Regex("\"([^\"]+)\"", Options)] = "[$+]", + [new Regex($@"(VALUES \([^)]*\)){Environment.NewLine}RETURNING \[Id\]", Options)] = $"OUTPUT INSERTED.[Id]{Environment.NewLine}$1" + }; + + private readonly DatabaseProvider _databaseProvider = databaseProvider; + + public string Adapt(string text, bool hasClientGeneratedId) + { + string replaced = text; + + if (_databaseProvider == DatabaseProvider.MySql) + { + replaced = replaced.Replace(@"""", "`"); + + string selectInsertId = hasClientGeneratedId ? $";{Environment.NewLine}SELECT @p1" : $";{Environment.NewLine}SELECT LAST_INSERT_ID()"; + replaced = replaced.Replace($"{Environment.NewLine}RETURNING `Id`", selectInsertId); + + replaced = replaced.Replace(@"\\", @"\\\\").Replace(@" ESCAPE '\'", @" ESCAPE '\\'"); + } + else if (_databaseProvider == DatabaseProvider.SqlServer) + { + foreach ((Regex regex, string replacementPattern) in SqlServerReplacements) + { + replaced = regex.Replace(replaced, replacementPattern); + } + } + + return replaced; + } +} diff --git a/test/DapperTests/IntegrationTests/TestFakers.cs b/test/DapperTests/IntegrationTests/TestFakers.cs new file mode 100644 index 0000000000..8b95338aea --- /dev/null +++ b/test/DapperTests/IntegrationTests/TestFakers.cs @@ -0,0 +1,61 @@ +using Bogus; +using DapperExample.Models; +using TestBuildingBlocks; +using Person = DapperExample.Models.Person; +using RgbColorType = DapperExample.Models.RgbColor; + +// @formatter:wrap_chained_method_calls chop_always +// @formatter:keep_existing_linebreaks true + +namespace DapperTests.IntegrationTests; + +internal sealed class TestFakers +{ + private readonly Lazy> _lazyTodoItemFaker = new(() => + new Faker() + .MakeDeterministic() + .RuleFor(todoItem => todoItem.Description, faker => faker.Lorem.Sentence()) + .RuleFor(todoItem => todoItem.Priority, faker => faker.Random.Enum()) + .RuleFor(todoItem => todoItem.DurationInHours, faker => faker.Random.Long(1, 250)) + .RuleFor(todoItem => todoItem.CreatedAt, faker => faker.Date.PastOffset() + .TruncateToWholeMilliseconds()) + .RuleFor(todoItem => todoItem.LastModifiedAt, faker => faker.Date.PastOffset() + .TruncateToWholeMilliseconds())); + + private readonly Lazy> _lazyLoginAccountFaker = new(() => + new Faker() + .MakeDeterministic() + .RuleFor(loginAccount => loginAccount.UserName, faker => faker.Internet.UserName()) + .RuleFor(loginAccount => loginAccount.LastUsedAt, faker => faker.Date.PastOffset() + .TruncateToWholeMilliseconds())); + + private readonly Lazy> _lazyAccountRecoveryFaker = new(() => + new Faker() + .MakeDeterministic() + .RuleFor(accountRecovery => accountRecovery.PhoneNumber, faker => faker.Person.Phone) + .RuleFor(accountRecovery => accountRecovery.EmailAddress, faker => faker.Person.Email)); + + private readonly Lazy> _lazyPersonFaker = new(() => + new Faker() + .MakeDeterministic() + .RuleFor(person => person.FirstName, faker => faker.Name.FirstName()) + .RuleFor(person => person.LastName, faker => faker.Name.LastName())); + + private readonly Lazy> _lazyTagFaker = new(() => + new Faker() + .MakeDeterministic() + .RuleFor(tag => tag.Name, faker => faker.Lorem.Word())); + + private readonly Lazy> _lazyRgbColorFaker = new(() => + new Faker() + .MakeDeterministic() + .RuleFor(rgbColor => rgbColor.Id, faker => RgbColorType.Create(faker.Random.Byte(), faker.Random.Byte(), faker.Random.Byte()) + .Id)); + + public Faker TodoItem => _lazyTodoItemFaker.Value; + public Faker Person => _lazyPersonFaker.Value; + public Faker LoginAccount => _lazyLoginAccountFaker.Value; + public Faker AccountRecovery => _lazyAccountRecoveryFaker.Value; + public Faker Tag => _lazyTagFaker.Value; + public Faker RgbColor => _lazyRgbColorFaker.Value; +} diff --git a/test/DapperTests/UnitTests/LogicalCombinatorTests.cs b/test/DapperTests/UnitTests/LogicalCombinatorTests.cs new file mode 100644 index 0000000000..de400c4f30 --- /dev/null +++ b/test/DapperTests/UnitTests/LogicalCombinatorTests.cs @@ -0,0 +1,49 @@ +using DapperExample.TranslationToSql.Transformations; +using DapperExample.TranslationToSql.TreeNodes; +using FluentAssertions; +using JsonApiDotNetCore.Queries.Expressions; +using Xunit; + +namespace DapperTests.UnitTests; + +public sealed class LogicalCombinatorTests +{ + [Fact] + public void Collapses_and_filters() + { + // Arrange + var column = new ColumnInTableNode("column", ColumnType.Scalar, null); + + var conditionLeft1 = new ComparisonNode(ComparisonOperator.GreaterThan, column, new ParameterNode("@p1", 10)); + var conditionRight1 = new ComparisonNode(ComparisonOperator.LessThan, column, new ParameterNode("@p2", 20)); + var and1 = new LogicalNode(LogicalOperator.And, conditionLeft1, conditionRight1); + + var conditionLeft2 = new ComparisonNode(ComparisonOperator.GreaterOrEqual, column, new ParameterNode("@p3", 100)); + var conditionRight2 = new ComparisonNode(ComparisonOperator.LessOrEqual, column, new ParameterNode("@p4", 200)); + var and2 = new LogicalNode(LogicalOperator.And, conditionLeft2, conditionRight2); + + var conditionLeft3 = new LikeNode(column, TextMatchKind.EndsWith, "Z"); + var conditionRight3 = new LikeNode(column, TextMatchKind.StartsWith, "A"); + var and3 = new LogicalNode(LogicalOperator.And, conditionLeft3, conditionRight3); + + var source = new LogicalNode(LogicalOperator.And, and1, new LogicalNode(LogicalOperator.And, and2, and3)); + var combinator = new LogicalCombinator(); + + // Act + FilterNode result = combinator.Collapse(source); + + // Assert + IEnumerable terms = new FilterNode[] + { + conditionLeft1, + conditionRight1, + conditionLeft2, + conditionRight2, + conditionLeft3, + conditionRight3 + }.Select(condition => condition.ToString()); + + string expectedText = $"({string.Join(") AND (", terms)})"; + result.ToString().Should().Be(expectedText); + } +} diff --git a/test/DapperTests/UnitTests/LogicalNodeTests.cs b/test/DapperTests/UnitTests/LogicalNodeTests.cs new file mode 100644 index 0000000000..6ce6dffab1 --- /dev/null +++ b/test/DapperTests/UnitTests/LogicalNodeTests.cs @@ -0,0 +1,22 @@ +using DapperExample.TranslationToSql.TreeNodes; +using FluentAssertions; +using JsonApiDotNetCore.Queries.Expressions; +using Xunit; + +namespace DapperTests.UnitTests; + +public sealed class LogicalNodeTests +{ + [Fact] + public void Throws_on_insufficient_terms() + { + // Arrange + var filter = new ComparisonNode(ComparisonOperator.Equals, new ParameterNode("@p1", null), new ParameterNode("@p2", null)); + + // Act + Action action = () => _ = new LogicalNode(LogicalOperator.And, filter); + + // Assert + action.Should().ThrowExactly().WithMessage("At least two terms are required.*"); + } +} diff --git a/test/DapperTests/UnitTests/ParameterNodeTests.cs b/test/DapperTests/UnitTests/ParameterNodeTests.cs new file mode 100644 index 0000000000..e199783fd1 --- /dev/null +++ b/test/DapperTests/UnitTests/ParameterNodeTests.cs @@ -0,0 +1,45 @@ +using DapperExample.TranslationToSql.TreeNodes; +using FluentAssertions; +using Xunit; + +namespace DapperTests.UnitTests; + +public sealed class ParameterNodeTests +{ + [Fact] + public void Throws_on_invalid_name() + { + // Act + Action action = () => _ = new ParameterNode("p1", null); + + // Assert + action.Should().ThrowExactly().WithMessage("Parameter name must start with an '@' symbol and not be empty.*"); + } + + [Theory] + [InlineData(null, "null")] + [InlineData(-123, "-123")] + [InlineData(123U, "123")] + [InlineData(-123L, "-123")] + [InlineData(123UL, "123")] + [InlineData((short)-123, "-123")] + [InlineData((ushort)123, "123")] + [InlineData('A', "'A'")] + [InlineData((sbyte)123, "123")] + [InlineData((byte)123, "0x7B")] + [InlineData(1.23F, "1.23")] + [InlineData(1.23D, "1.23")] + [InlineData("123", "'123'")] + [InlineData(DayOfWeek.Saturday, "DayOfWeek.Saturday")] + public void Can_format_parameter(object? parameterValue, string formattedValueExpected) + { + // Arrange + var parameter = new ParameterNode("@name", parameterValue); + + // Act + string text = parameter.ToString(); + + // Assert + text.Should().Be($"@name = {formattedValueExpected}"); + } +} diff --git a/test/DapperTests/UnitTests/RelationshipForeignKeyTests.cs b/test/DapperTests/UnitTests/RelationshipForeignKeyTests.cs new file mode 100644 index 0000000000..fe58b3b183 --- /dev/null +++ b/test/DapperTests/UnitTests/RelationshipForeignKeyTests.cs @@ -0,0 +1,58 @@ +using DapperExample; +using DapperExample.TranslationToSql.DataModel; +using FluentAssertions; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace DapperTests.UnitTests; + +public sealed class RelationshipForeignKeyTests +{ + private readonly IResourceGraph _resourceGraph = + new ResourceGraphBuilder(new JsonApiOptions(), NullLoggerFactory.Instance).Add().Build(); + + [Fact] + public void Can_format_foreign_key_for_ToOne_relationship() + { + // Arrange + RelationshipAttribute parentRelationship = _resourceGraph.GetResourceType().GetRelationshipByPropertyName(nameof(TestResource.Parent)); + + // Act + var foreignKey = new RelationshipForeignKey(DatabaseProvider.PostgreSql, parentRelationship, true, "ParentId", true); + + // Assert + foreignKey.ToString().Should().Be(""" + TestResource.Parent => "TestResources"."ParentId"? + """); + } + + [Fact] + public void Can_format_foreign_key_for_ToMany_relationship() + { + // Arrange + RelationshipAttribute childrenRelationship = + _resourceGraph.GetResourceType().GetRelationshipByPropertyName(nameof(TestResource.Children)); + + // Act + var foreignKey = new RelationshipForeignKey(DatabaseProvider.PostgreSql, childrenRelationship, false, "TestResourceId", false); + + // Assert + foreignKey.ToString().Should().Be(""" + TestResource.Children => "TestResources"."TestResourceId" + """); + } + + [UsedImplicitly] + private sealed class TestResource : Identifiable + { + [HasOne] + public TestResource? Parent { get; set; } + + [HasMany] + public ISet Children { get; set; } = new HashSet(); + } +} diff --git a/test/DapperTests/UnitTests/SqlTreeNodeVisitorTests.cs b/test/DapperTests/UnitTests/SqlTreeNodeVisitorTests.cs new file mode 100644 index 0000000000..53ef375e0a --- /dev/null +++ b/test/DapperTests/UnitTests/SqlTreeNodeVisitorTests.cs @@ -0,0 +1,45 @@ +using System.Reflection; +using DapperExample.TranslationToSql; +using DapperExample.TranslationToSql.TreeNodes; +using FluentAssertions; +using Xunit; + +namespace DapperTests.UnitTests; + +public sealed class SqlTreeNodeVisitorTests +{ + [Fact] + public void Visitor_methods_call_default_visit() + { + // Arrange + var visitor = new TestVisitor(); + + MethodInfo[] visitMethods = visitor.GetType().GetMethods() + .Where(method => method.Name.StartsWith("Visit", StringComparison.Ordinal) && method.Name != "Visit").ToArray(); + + object?[] parameters = + [ + null, + null + ]; + + // Act + foreach (MethodInfo method in visitMethods) + { + _ = method.Invoke(visitor, parameters); + } + + visitor.HitCount.Should().Be(26); + } + + private sealed class TestVisitor : SqlTreeNodeVisitor + { + public int HitCount { get; private set; } + + public override object? DefaultVisit(SqlTreeNode node, object? argument) + { + HitCount++; + return base.DefaultVisit(node, argument); + } + } +} diff --git a/test/DiscoveryTests/AspNetOpenApiTests.cs b/test/DiscoveryTests/AspNetOpenApiTests.cs new file mode 100644 index 0000000000..06dfcee914 --- /dev/null +++ b/test/DiscoveryTests/AspNetOpenApiTests.cs @@ -0,0 +1,33 @@ +#if !NET8_0 +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace DiscoveryTests; + +public sealed class AspNetOpenApiTests +{ + [Fact] + public async Task Throws_when_AspNet_OpenApi_is_registered() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions()); + builder.WebHost.UseTestServer(); + builder.Services.AddJsonApi(); + builder.Services.AddOpenApi(); + await using WebApplication app = builder.Build(); + + // Act + Action action = app.UseJsonApi; + + // Assert + action.Should().ThrowExactly().WithMessage("JsonApiDotNetCore is incompatible with ASP.NET OpenAPI. " + + "Remove 'services.AddOpenApi()', or replace it by calling 'services.AddOpenApiForJsonApi()' after 'services.AddJsonApi()' " + + "from the JsonApiDotNetCore.OpenApi.Swashbuckle NuGet package."); + } +} +#endif diff --git a/test/DiscoveryTests/DiscoveryTests.csproj b/test/DiscoveryTests/DiscoveryTests.csproj index 2f1048de3f..11567b9113 100644 --- a/test/DiscoveryTests/DiscoveryTests.csproj +++ b/test/DiscoveryTests/DiscoveryTests.csproj @@ -1,13 +1,9 @@ - + - $(TargetFrameworkName) + net9.0;net8.0 - - - PreserveNewest - - + @@ -16,7 +12,9 @@ + - + + diff --git a/test/DiscoveryTests/LoggingTests.cs b/test/DiscoveryTests/LoggingTests.cs new file mode 100644 index 0000000000..9afb7c7888 --- /dev/null +++ b/test/DiscoveryTests/LoggingTests.cs @@ -0,0 +1,52 @@ +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Repositories; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using TestBuildingBlocks; +using Xunit; + +namespace DiscoveryTests; + +public sealed class LoggingTests +{ + [Fact] + public async Task Logs_message_to_add_NuGet_reference() + { + // Arrange + using var loggerProvider = + new CapturingLoggerProvider((category, _) => category.StartsWith("JsonApiDotNetCore.Repositories", StringComparison.Ordinal)); + + WebApplicationBuilder builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions()); + builder.Logging.AddProvider(loggerProvider); + builder.Logging.SetMinimumLevel(LogLevel.Debug); + builder.Services.AddDbContext(options => options.UseInMemoryDatabase(Guid.NewGuid().ToString())); + builder.Services.AddJsonApi(); + builder.WebHost.UseTestServer(); + await using WebApplication app = builder.Build(); + + var resourceGraph = app.Services.GetRequiredService(); + ResourceType resourceType = resourceGraph.GetResourceType(); + + var repository = app.Services.GetRequiredService>(); + + // Act + _ = await repository.GetAsync(new QueryLayer(resourceType), CancellationToken.None); + + // Assert + IReadOnlyList logLines = loggerProvider.GetLines(); + + logLines.Should().Contain( + "[DEBUG] Failed to load assembly. To log expression trees, add a NuGet reference to 'AgileObjects.ReadableExpressions' in your project."); + } + + private sealed class TestDbContext(DbContextOptions options) + : DbContext(options) + { + public DbSet PrivateResources => Set(); + } +} diff --git a/test/DiscoveryTests/PrivateResource.cs b/test/DiscoveryTests/PrivateResource.cs index ed6dc23204..9ad2daef51 100644 --- a/test/DiscoveryTests/PrivateResource.cs +++ b/test/DiscoveryTests/PrivateResource.cs @@ -1,9 +1,9 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; namespace DiscoveryTests; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class PrivateResource : Identifiable -{ -} +[Resource] +public sealed class PrivateResource : Identifiable; diff --git a/test/DiscoveryTests/PrivateResourceDefinition.cs b/test/DiscoveryTests/PrivateResourceDefinition.cs index 25ff719718..3003ce389c 100644 --- a/test/DiscoveryTests/PrivateResourceDefinition.cs +++ b/test/DiscoveryTests/PrivateResourceDefinition.cs @@ -5,10 +5,5 @@ namespace DiscoveryTests; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class PrivateResourceDefinition : JsonApiResourceDefinition -{ - public PrivateResourceDefinition(IResourceGraph resourceGraph) - : base(resourceGraph) - { - } -} +public sealed class PrivateResourceDefinition(IResourceGraph resourceGraph) + : JsonApiResourceDefinition(resourceGraph); diff --git a/test/DiscoveryTests/PrivateResourceRepository.cs b/test/DiscoveryTests/PrivateResourceRepository.cs index cb654ea724..eb33d18440 100644 --- a/test/DiscoveryTests/PrivateResourceRepository.cs +++ b/test/DiscoveryTests/PrivateResourceRepository.cs @@ -8,12 +8,8 @@ namespace DiscoveryTests; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class PrivateResourceRepository : EntityFrameworkCoreRepository -{ - public PrivateResourceRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, - IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) - { - } -} +public sealed class PrivateResourceRepository( + ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, + IEnumerable constraintProviders, ILoggerFactory loggerFactory, IResourceDefinitionAccessor resourceDefinitionAccessor) + : EntityFrameworkCoreRepository(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, + resourceDefinitionAccessor); diff --git a/test/DiscoveryTests/PrivateResourceService.cs b/test/DiscoveryTests/PrivateResourceService.cs index 6d289eafb1..bad4b41428 100644 --- a/test/DiscoveryTests/PrivateResourceService.cs +++ b/test/DiscoveryTests/PrivateResourceService.cs @@ -10,12 +10,9 @@ namespace DiscoveryTests; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class PrivateResourceService : JsonApiResourceService -{ - public PrivateResourceService(IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, - IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, - IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, resourceDefinitionAccessor) - { - } -} +public sealed class PrivateResourceService( + IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, + ILoggerFactory loggerFactory, IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, + IResourceDefinitionAccessor resourceDefinitionAccessor) + : JsonApiResourceService(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, + resourceChangeTracker, resourceDefinitionAccessor); diff --git a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs index 3396aed54b..eeca3cdb89 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,92 +8,70 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -using Moq; -using TestBuildingBlocks; using Xunit; 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 = []; 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(); + personType.Should().NotBeNull(); ResourceType? todoItemType = resourceGraph.FindResourceType(typeof(TodoItem)); - todoItemType.ShouldNotBeNull(); + todoItemType.Should().NotBeNull(); } [Fact] 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.Should().NotBeNull(); } [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 +79,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 +95,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/DiscoveryTests/xunit.runner.json b/test/DiscoveryTests/xunit.runner.json deleted file mode 100644 index 9db029ba52..0000000000 --- a/test/DiscoveryTests/xunit.runner.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "parallelizeAssembly": false, - "parallelizeTestCollections": false -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/ArchiveTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/ArchiveTests.cs index 2af39b3d26..97506ec5d6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/ArchiveTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/ArchiveTests.cs @@ -21,17 +21,14 @@ public ArchiveTests(IntegrationTestContext, testContext.UseController(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddResourceDefinition(); - }); + testContext.ConfigureServices(services => services.AddResourceDefinition()); } [Fact] public async Task Can_get_archived_resource_by_ID() { // Arrange - TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); + TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -47,16 +44,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(broadcast.StringId); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("archivedAt").With(value => value.Should().Be(broadcast.ArchivedAt)); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("archivedAt").WhoseValue.Should().Be(broadcast.ArchivedAt); } [Fact] public async Task Can_get_unarchived_resource_by_ID() { // Arrange - TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); + TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.GenerateOne(); broadcast.ArchivedAt = null; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -73,16 +70,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(broadcast.StringId); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("archivedAt").WhoseValue.Should().BeNull(); } [Fact] public async Task Get_primary_resources_excludes_archived() { // Arrange - List broadcasts = _fakers.TelevisionBroadcast.Generate(2); + List broadcasts = _fakers.TelevisionBroadcast.GenerateList(2); broadcasts[1].ArchivedAt = null; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -100,16 +97,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(broadcasts[1].StringId); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("archivedAt").WhoseValue.Should().BeNull(); } [Fact] public async Task Get_primary_resources_with_filter_includes_archived() { // Arrange - List broadcasts = _fakers.TelevisionBroadcast.Generate(2); + List broadcasts = _fakers.TelevisionBroadcast.GenerateList(2); broadcasts[1].ArchivedAt = null; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -127,20 +124,20 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue.Should().HaveCount(2); responseDocument.Data.ManyValue[0].Id.Should().Be(broadcasts[0].StringId); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().Be(broadcasts[0].ArchivedAt)); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("archivedAt").WhoseValue.Should().Be(broadcasts[0].ArchivedAt); responseDocument.Data.ManyValue[1].Id.Should().Be(broadcasts[1].StringId); - responseDocument.Data.ManyValue[1].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); + responseDocument.Data.ManyValue[1].Attributes.Should().ContainKey("archivedAt").WhoseValue.Should().BeNull(); } [Fact] public async Task Get_primary_resource_by_ID_with_include_excludes_archived() { // Arrange - TelevisionStation station = _fakers.TelevisionStation.Generate(); - station.Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); + TelevisionStation station = _fakers.TelevisionStation.GenerateOne(); + station.Broadcasts = _fakers.TelevisionBroadcast.GenerateSet(2); station.Broadcasts.ElementAt(1).ArchivedAt = null; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -157,20 +154,20 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(station.StringId); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); - responseDocument.Included[0].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); + responseDocument.Included[0].Attributes.Should().ContainKey("archivedAt").WhoseValue.Should().BeNull(); } [Fact] public async Task Get_primary_resource_by_ID_with_include_and_filter_includes_archived() { // Arrange - TelevisionStation station = _fakers.TelevisionStation.Generate(); - station.Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); + TelevisionStation station = _fakers.TelevisionStation.GenerateOne(); + station.Broadcasts = _fakers.TelevisionBroadcast.GenerateSet(2); station.Broadcasts.ElementAt(1).ArchivedAt = null; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -187,22 +184,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(station.StringId); - responseDocument.Included.ShouldHaveCount(2); + responseDocument.Included.Should().HaveCount(2); responseDocument.Included[0].Id.Should().Be(station.Broadcasts.ElementAt(0).StringId); - responseDocument.Included[0].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().Be(station.Broadcasts.ElementAt(0).ArchivedAt)); + responseDocument.Included[0].Attributes.Should().ContainKey("archivedAt").WhoseValue.Should().Be(station.Broadcasts.ElementAt(0).ArchivedAt); responseDocument.Included[1].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); - responseDocument.Included[1].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); + responseDocument.Included[1].Attributes.Should().ContainKey("archivedAt").WhoseValue.Should().BeNull(); } [Fact] public async Task Get_secondary_resource_includes_archived() { // Arrange - BroadcastComment comment = _fakers.BroadcastComment.Generate(); - comment.AppliesTo = _fakers.TelevisionBroadcast.Generate(); + BroadcastComment comment = _fakers.BroadcastComment.GenerateOne(); + comment.AppliesTo = _fakers.TelevisionBroadcast.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -218,17 +215,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(comment.AppliesTo.StringId); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("archivedAt").With(value => value.Should().Be(comment.AppliesTo.ArchivedAt)); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("archivedAt").WhoseValue.Should().Be(comment.AppliesTo.ArchivedAt); } [Fact] public async Task Get_secondary_resources_excludes_archived() { // Arrange - TelevisionStation station = _fakers.TelevisionStation.Generate(); - station.Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); + TelevisionStation station = _fakers.TelevisionStation.GenerateOne(); + station.Broadcasts = _fakers.TelevisionBroadcast.GenerateSet(2); station.Broadcasts.ElementAt(1).ArchivedAt = null; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -245,17 +242,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("archivedAt").WhoseValue.Should().BeNull(); } [Fact] public async Task Get_secondary_resources_with_filter_includes_archived() { // Arrange - TelevisionStation station = _fakers.TelevisionStation.Generate(); - station.Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); + TelevisionStation station = _fakers.TelevisionStation.GenerateOne(); + station.Broadcasts = _fakers.TelevisionBroadcast.GenerateSet(2); station.Broadcasts.ElementAt(1).ArchivedAt = null; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -274,21 +271,21 @@ await _testContext.RunOnDatabaseAsync(async dbContext => DateTimeOffset archivedAt0 = station.Broadcasts.ElementAt(0).ArchivedAt!.Value; - responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue.Should().HaveCount(2); responseDocument.Data.ManyValue[0].Id.Should().Be(station.Broadcasts.ElementAt(0).StringId); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().Be(archivedAt0)); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("archivedAt").WhoseValue.Should().Be(archivedAt0); responseDocument.Data.ManyValue[1].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); - responseDocument.Data.ManyValue[1].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); + responseDocument.Data.ManyValue[1].Attributes.Should().ContainKey("archivedAt").WhoseValue.Should().BeNull(); } [Fact] public async Task Get_secondary_resource_by_ID_with_include_excludes_archived() { // Arrange - TelevisionNetwork network = _fakers.TelevisionNetwork.Generate(); - network.Stations = _fakers.TelevisionStation.Generate(1).ToHashSet(); - network.Stations.ElementAt(0).Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); + TelevisionNetwork network = _fakers.TelevisionNetwork.GenerateOne(); + network.Stations = _fakers.TelevisionStation.GenerateSet(1); + network.Stations.ElementAt(0).Broadcasts = _fakers.TelevisionBroadcast.GenerateSet(2); network.Stations.ElementAt(0).Broadcasts.ElementAt(1).ArchivedAt = null; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -305,20 +302,20 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(network.Stations.ElementAt(0).StringId); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Id.Should().Be(network.Stations.ElementAt(0).Broadcasts.ElementAt(1).StringId); - responseDocument.Included[0].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); + responseDocument.Included[0].Attributes.Should().ContainKey("archivedAt").WhoseValue.Should().BeNull(); } [Fact] public async Task Get_secondary_resource_by_ID_with_include_and_filter_includes_archived() { - TelevisionNetwork network = _fakers.TelevisionNetwork.Generate(); - network.Stations = _fakers.TelevisionStation.Generate(1).ToHashSet(); - network.Stations.ElementAt(0).Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); + TelevisionNetwork network = _fakers.TelevisionNetwork.GenerateOne(); + network.Stations = _fakers.TelevisionStation.GenerateSet(1); + network.Stations.ElementAt(0).Broadcasts = _fakers.TelevisionBroadcast.GenerateSet(2); network.Stations.ElementAt(0).Broadcasts.ElementAt(1).ArchivedAt = null; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -338,22 +335,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => DateTimeOffset archivedAt0 = network.Stations.ElementAt(0).Broadcasts.ElementAt(0).ArchivedAt!.Value; - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(network.Stations.ElementAt(0).StringId); - responseDocument.Included.ShouldHaveCount(2); + responseDocument.Included.Should().HaveCount(2); responseDocument.Included[0].Id.Should().Be(network.Stations.ElementAt(0).Broadcasts.ElementAt(0).StringId); - responseDocument.Included[0].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().Be(archivedAt0)); + responseDocument.Included[0].Attributes.Should().ContainKey("archivedAt").WhoseValue.Should().Be(archivedAt0); responseDocument.Included[1].Id.Should().Be(network.Stations.ElementAt(0).Broadcasts.ElementAt(1).StringId); - responseDocument.Included[1].Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); + responseDocument.Included[1].Attributes.Should().ContainKey("archivedAt").WhoseValue.Should().BeNull(); } [Fact] public async Task Get_ToMany_relationship_excludes_archived() { // Arrange - TelevisionStation station = _fakers.TelevisionStation.Generate(); - station.Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); + TelevisionStation station = _fakers.TelevisionStation.GenerateOne(); + station.Broadcasts = _fakers.TelevisionBroadcast.GenerateSet(2); station.Broadcasts.ElementAt(1).ArchivedAt = null; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -370,7 +367,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); } @@ -378,8 +375,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Get_ToMany_relationship_with_filter_includes_archived() { // Arrange - TelevisionStation station = _fakers.TelevisionStation.Generate(); - station.Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); + TelevisionStation station = _fakers.TelevisionStation.GenerateOne(); + station.Broadcasts = _fakers.TelevisionBroadcast.GenerateSet(2); station.Broadcasts.ElementAt(1).ArchivedAt = null; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -396,7 +393,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue.Should().HaveCount(2); responseDocument.Data.ManyValue[0].Id.Should().Be(station.Broadcasts.ElementAt(0).StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); } @@ -405,7 +402,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_unarchived_resource() { // Arrange - TelevisionBroadcast newBroadcast = _fakers.TelevisionBroadcast.Generate(); + TelevisionBroadcast newBroadcast = _fakers.TelevisionBroadcast.GenerateOne(); var requestBody = new { @@ -428,17 +425,17 @@ public async Task Can_create_unarchived_resource() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newBroadcast.Title)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("airedAt").With(value => value.Should().Be(newBroadcast.AiredAt)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("archivedAt").With(value => value.Should().BeNull()); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("title").WhoseValue.Should().Be(newBroadcast.Title); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("airedAt").WhoseValue.Should().Be(newBroadcast.AiredAt); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("archivedAt").WhoseValue.Should().BeNull(); } [Fact] public async Task Cannot_create_archived_resource() { // Arrange - TelevisionBroadcast newBroadcast = _fakers.TelevisionBroadcast.Generate(); + TelevisionBroadcast newBroadcast = _fakers.TelevisionBroadcast.GenerateOne(); var requestBody = new { @@ -462,7 +459,7 @@ public async Task Cannot_create_archived_resource() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Forbidden); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Forbidden); @@ -474,10 +471,10 @@ public async Task Cannot_create_archived_resource() public async Task Can_archive_resource() { // Arrange - TelevisionBroadcast existingBroadcast = _fakers.TelevisionBroadcast.Generate(); + TelevisionBroadcast existingBroadcast = _fakers.TelevisionBroadcast.GenerateOne(); existingBroadcast.ArchivedAt = null; - DateTimeOffset newArchivedAt = _fakers.TelevisionBroadcast.Generate().ArchivedAt!.Value; + DateTimeOffset newArchivedAt = _fakers.TelevisionBroadcast.GenerateOne().ArchivedAt!.Value; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -520,7 +517,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_unarchive_resource() { // Arrange - TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); + TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -563,9 +560,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_shift_archive_date() { // Arrange - TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); + TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.GenerateOne(); - DateTimeOffset? newArchivedAt = _fakers.TelevisionBroadcast.Generate().ArchivedAt; + DateTimeOffset? newArchivedAt = _fakers.TelevisionBroadcast.GenerateOne().ArchivedAt; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -594,7 +591,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Forbidden); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Forbidden); @@ -606,7 +603,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_delete_archived_resource() { // Arrange - TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); + TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -636,7 +633,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_delete_unarchived_resource() { // Arrange - TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); + TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.GenerateOne(); broadcast.ArchivedAt = null; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -653,7 +650,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Forbidden); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Forbidden); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs index 4037f59b62..5ec385081b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs @@ -13,23 +13,16 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class TelevisionBroadcastDefinition : JsonApiResourceDefinition +public sealed class TelevisionBroadcastDefinition( + IResourceGraph resourceGraph, TelevisionDbContext dbContext, IJsonApiRequest request, IEnumerable constraintProviders) + : JsonApiResourceDefinition(resourceGraph) { - private readonly TelevisionDbContext _dbContext; - private readonly IJsonApiRequest _request; - private readonly IEnumerable _constraintProviders; + private readonly TelevisionDbContext _dbContext = dbContext; + private readonly IJsonApiRequest _request = request; + private readonly IEnumerable _constraintProviders = constraintProviders; private DateTimeOffset? _storedArchivedAt; - public TelevisionBroadcastDefinition(IResourceGraph resourceGraph, TelevisionDbContext dbContext, IJsonApiRequest request, - IEnumerable constraintProviders) - : base(resourceGraph) - { - _dbContext = dbContext; - _request = request; - _constraintProviders = constraintProviders; - } - public override FilterExpression? OnApplyFilter(FilterExpression? existingFilter) { if (_request.IsReadOnly) @@ -71,7 +64,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 +73,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 +173,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/TelevisionDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionDbContext.cs index 471f471028..fe83dace37 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionDbContext.cs @@ -1,18 +1,15 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; namespace JsonApiDotNetCoreTests.IntegrationTests.Archiving; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class TelevisionDbContext : DbContext +public sealed class TelevisionDbContext(DbContextOptions options) + : TestableDbContext(options) { public DbSet Networks => Set(); public DbSet Stations => Set(); public DbSet Broadcasts => Set(); public DbSet Comments => Set(); - - public TelevisionDbContext(DbContextOptions options) - : base(options) - { - } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Archiving/TelevisionFakers.cs index 6c3116d789..3fd7fb7b5c 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 +internal sealed class TelevisionFakers { - private readonly Lazy> _lazyTelevisionNetworkFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(network => network.Name, faker => faker.Company.CompanyName())); + private readonly Lazy> _lazyTelevisionNetworkFaker = new(() => new Faker() + .MakeDeterministic() + .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() + .MakeDeterministic() + .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() + .MakeDeterministic() + .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() + .MakeDeterministic() + .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/Controllers/AtomicConstrainedOperationsControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs deleted file mode 100644 index e56e9119bf..0000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs +++ /dev/null @@ -1,215 +0,0 @@ -using System.Net; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Controllers; - -public sealed class AtomicConstrainedOperationsControllerTests - : IClassFixture, OperationsDbContext>> -{ - private readonly IntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new(); - - public AtomicConstrainedOperationsControllerTests(IntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task Can_create_resources_for_matching_resource_type() - { - // Arrange - string newTitle1 = _fakers.MusicTrack.Generate().Title; - string newTitle2 = _fakers.MusicTrack.Generate().Title; - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTitle1 - } - } - }, - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTitle2 - } - } - } - } - }; - - const string route = "/operations/musicTracks/create"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.ShouldHaveCount(2); - } - - [Fact] - public async Task Cannot_create_resource_for_mismatching_resource_type() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "performers", - attributes = new - { - } - } - } - } - }; - - const string route = "/operations/musicTracks/create"; - - // 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("Unsupported combination of operation code and resource type at this endpoint."); - error.Detail.Should().Be("This endpoint can only be used to create resources of type 'musicTracks'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_update_resources_for_matching_resource_type() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - attributes = new - { - } - } - } - } - }; - - const string route = "/operations/musicTracks/create"; - - // 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("Unsupported combination of operation code and resource type at this endpoint."); - error.Detail.Should().Be("This endpoint can only be used to create resources of type 'musicTracks'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_add_to_ToMany_relationship_for_matching_resource_type() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - Performer existingPerformer = _fakers.Performer.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddInRange(existingTrack, existingPerformer); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - id = existingPerformer.StringId - } - } - } - } - }; - - const string route = "/operations/musicTracks/create"; - - // 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("Unsupported combination of operation code and resource type at this endpoint."); - error.Detail.Should().Be("This endpoint can only be used to create resources of type 'musicTracks'."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicCustomConstrainedOperationsControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicCustomConstrainedOperationsControllerTests.cs new file mode 100644 index 0000000000..b515ff7c13 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicCustomConstrainedOperationsControllerTests.cs @@ -0,0 +1,215 @@ +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Controllers; + +public sealed class AtomicCustomConstrainedOperationsControllerTests + : IClassFixture, OperationsDbContext>> +{ + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); + + public AtomicCustomConstrainedOperationsControllerTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + } + + [Fact] + public async Task Can_create_resources_for_matching_resource_type() + { + // Arrange + string newTitle1 = _fakers.MusicTrack.GenerateOne().Title; + string newTitle2 = _fakers.MusicTrack.GenerateOne().Title; + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + attributes = new + { + title = newTitle1 + } + } + }, + new + { + op = "add", + data = new + { + type = "musicTracks", + attributes = new + { + title = newTitle2 + } + } + } + } + }; + + const string route = "/operations/musicTracks/create"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(2); + } + + [Fact] + public async Task Cannot_create_resource_for_inaccessible_operation() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "performers", + attributes = new + { + } + } + } + } + }; + + const string route = "/operations/musicTracks/create"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested operation is not accessible."); + error.Detail.Should().Be("The 'add' resource operation is not accessible for resource type 'performers'."); + error.Source.Should().NotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_update_resource_for_inaccessible_operation() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + attributes = new + { + } + } + } + } + }; + + const string route = "/operations/musicTracks/create"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested operation is not accessible."); + error.Detail.Should().Be("The 'update' resource operation is not accessible for resource type 'musicTracks'."); + error.Source.Should().NotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_add_to_ToMany_relationship_for_inaccessible_operation() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + Performer existingPerformer = _fakers.Performer.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingTrack, existingPerformer); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "performers" + }, + data = new[] + { + new + { + type = "performers", + id = existingPerformer.StringId + } + } + } + } + }; + + const string route = "/operations/musicTracks/create"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested operation is not accessible."); + error.Detail.Should().Be("The 'add' relationship operation is not accessible for relationship 'performers' on resource type 'musicTracks'."); + error.Source.Should().NotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]"); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicDefaultConstrainedOperationsControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicDefaultConstrainedOperationsControllerTests.cs new file mode 100644 index 0000000000..bec1bdc883 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/AtomicDefaultConstrainedOperationsControllerTests.cs @@ -0,0 +1,173 @@ +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Objects; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Controllers; + +public sealed class AtomicDefaultConstrainedOperationsControllerTests + : IClassFixture, OperationsDbContext>> +{ + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); + + public AtomicDefaultConstrainedOperationsControllerTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + } + + [Fact] + public async Task Cannot_delete_resource_for_inaccessible_operation() + { + // Arrange + TextLanguage existingLanguage = _fakers.TextLanguage.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingLanguage); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "textLanguages", + id = existingLanguage.StringId + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error.Title.Should().Be("The requested operation is not accessible."); + error.Detail.Should().Be("The 'remove' resource operation is not accessible for resource type 'textLanguages'."); + error.Source.Should().NotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]"); + } + + [Fact] + public async Task Cannot_change_ToMany_relationship_for_inaccessible_operations() + { + // Arrange + TextLanguage existingLanguage = _fakers.TextLanguage.GenerateOne(); + Lyric existingLyric = _fakers.Lyric.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingLanguage, existingLyric); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "textLanguages", + id = existingLanguage.StringId, + relationship = "lyrics" + }, + data = new[] + { + new + { + type = "lyrics", + id = existingLyric.StringId + } + } + }, + new + { + op = "add", + @ref = new + { + type = "textLanguages", + id = existingLanguage.StringId, + relationship = "lyrics" + }, + data = new[] + { + new + { + type = "lyrics", + id = existingLyric.StringId + } + } + }, + new + { + op = "remove", + @ref = new + { + type = "textLanguages", + id = existingLanguage.StringId, + relationship = "lyrics" + }, + data = new[] + { + new + { + type = "lyrics", + id = existingLyric.StringId + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Forbidden); + + responseDocument.Errors.Should().HaveCount(3); + + ErrorObject error1 = responseDocument.Errors[0]; + error1.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error1.Title.Should().Be("The requested operation is not accessible."); + error1.Detail.Should().Be("The 'update' relationship operation is not accessible for relationship 'lyrics' on resource type 'textLanguages'."); + error1.Source.Should().NotBeNull(); + error1.Source.Pointer.Should().Be("/atomic:operations[0]"); + + ErrorObject error2 = responseDocument.Errors[1]; + error2.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error2.Title.Should().Be("The requested operation is not accessible."); + error2.Detail.Should().Be("The 'add' relationship operation is not accessible for relationship 'lyrics' on resource type 'textLanguages'."); + error2.Source.Should().NotBeNull(); + error2.Source.Pointer.Should().Be("/atomic:operations[1]"); + + ErrorObject error3 = responseDocument.Errors[2]; + error3.StatusCode.Should().Be(HttpStatusCode.Forbidden); + error3.Title.Should().Be("The requested operation is not accessible."); + error3.Detail.Should().Be("The 'remove' relationship operation is not accessible for relationship 'lyrics' on resource type 'textLanguages'."); + error3.Source.Should().NotBeNull(); + error3.Source.Pointer.Should().Be("/atomic:operations[2]"); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs index 517cf4c792..81efcf9b1d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs @@ -1,12 +1,9 @@ -using System.Net; using JsonApiDotNetCore.AtomicOperations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Controllers.Annotations; -using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -14,41 +11,22 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Controllers; [DisableRoutingConvention] [Route("/operations/musicTracks/create")] -public sealed class CreateMusicTrackOperationsController : JsonApiOperationsController +public sealed class CreateMusicTrackOperationsController( + IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request, + ITargetedFields targetedFields) + : JsonApiOperationsController(options, resourceGraph, loggerFactory, processor, request, targetedFields, OnlyCreateMusicTracksOperationFilter.Instance) { - public CreateMusicTrackOperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IOperationsProcessor processor, IJsonApiRequest request, ITargetedFields targetedFields) - : base(options, resourceGraph, loggerFactory, processor, request, targetedFields) + private sealed class OnlyCreateMusicTracksOperationFilter : IAtomicOperationFilter { - } - - public override async Task PostOperationsAsync(IList operations, CancellationToken cancellationToken) - { - AssertOnlyCreatingMusicTracks(operations); - - return await base.PostOperationsAsync(operations, cancellationToken); - } + public static readonly OnlyCreateMusicTracksOperationFilter Instance = new(); - private static void AssertOnlyCreatingMusicTracks(IEnumerable operations) - { - int index = 0; - - foreach (OperationContainer operation in operations) + private OnlyCreateMusicTracksOperationFilter() { - if (operation.Request.WriteOperation != WriteOperationKind.CreateResource || operation.Resource.GetType() != typeof(MusicTrack)) - { - throw new JsonApiException(new ErrorObject(HttpStatusCode.UnprocessableEntity) - { - Title = "Unsupported combination of operation code and resource type at this endpoint.", - Detail = "This endpoint can only be used to create resources of type 'musicTracks'.", - Source = new ErrorSource - { - Pointer = $"/atomic:operations[{index}]" - } - }); - } + } - index++; + public bool IsEnabled(ResourceType resourceType, WriteOperationKind writeOperation) + { + return writeOperation == WriteOperationKind.CreateResource && resourceType.ClrType == typeof(MusicTrack); } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AssignIdToTextLanguageDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AssignIdToTextLanguageDefinition.cs new file mode 100644 index 0000000000..730f255af9 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AssignIdToTextLanguageDefinition.cs @@ -0,0 +1,20 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; + +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Creating; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class AssignIdToTextLanguageDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter, OperationsDbContext dbContext) + : ImplicitlyChangingTextLanguageDefinition(resourceGraph, hitCounter, dbContext) +{ + public override Task OnWritingAsync(TextLanguage resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (writeOperation == WriteOperationKind.CreateResource && resource.Id == Guid.Empty) + { + resource.Id = Guid.NewGuid(); + } + + return Task.CompletedTask; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs index ffae461fb0..8b0090fe8e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs @@ -34,8 +34,8 @@ public AtomicCreateResourceTests(IntegrationTestContext + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("performers"); - resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName)); - resource.Attributes.ShouldContainKey("bornAt").With(value => value.Should().Be(newBornAt)); + resource.Attributes.Should().ContainKey("artistName").WhoseValue.Should().Be(newArtistName); + resource.Attributes.Should().ContainKey("bornAt").WhoseValue.Should().Be(newBornAt); resource.Relationships.Should().BeNull(); }); - int newPerformerId = int.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + int newPerformerId = int.Parse(responseDocument.Results[0].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -92,7 +92,7 @@ public async Task Can_create_resources() // Arrange const int elementCount = 5; - List newTracks = _fakers.MusicTrack.Generate(elementCount); + List newTracks = _fakers.MusicTrack.GenerateList(elementCount); var operationElements = new List(elementCount); @@ -128,33 +128,30 @@ public async Task Can_create_resources() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(elementCount); + responseDocument.Results.Should().HaveCount(elementCount); for (int index = 0; index < elementCount; index++) { - responseDocument.Results[index].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[index].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { - resource.ShouldNotBeNull(); + resource.Should().NotBeNull(); resource.Type.Should().Be("musicTracks"); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTracks[index].Title)); + resource.Attributes.Should().ContainKey("title").WhoseValue.Should().Be(newTracks[index].Title); + resource.Attributes.Should().ContainKey("lengthInSeconds").WhoseValue.As().Should().BeApproximately(newTracks[index].LengthInSeconds); + resource.Attributes.Should().ContainKey("genre").WhoseValue.Should().Be(newTracks[index].Genre); + resource.Attributes.Should().ContainKey("releasedAt").WhoseValue.Should().Be(newTracks[index].ReleasedAt); - resource.Attributes.ShouldContainKey("lengthInSeconds") - .With(value => value.As().Should().BeApproximately(newTracks[index].LengthInSeconds)); - - resource.Attributes.ShouldContainKey("genre").With(value => value.Should().Be(newTracks[index].Genre)); - resource.Attributes.ShouldContainKey("releasedAt").With(value => value.Should().Be(newTracks[index].ReleasedAt)); - - resource.Relationships.ShouldNotBeEmpty(); + resource.Relationships.Should().NotBeEmpty(); }); } - Guid[] newTrackIds = responseDocument.Results.Select(result => Guid.Parse(result.Data.SingleValue!.Id.ShouldNotBeNull())).ToArray(); + Guid[] newTrackIds = responseDocument.Results.Select(result => Guid.Parse(result.Data.SingleValue!.Id.Should().NotBeNull().And.Subject)).ToArray(); await _testContext.RunOnDatabaseAsync(async dbContext => { List tracksInDatabase = await dbContext.MusicTracks.Where(musicTrack => newTrackIds.Contains(musicTrack.Id)).ToListAsync(); - tracksInDatabase.ShouldHaveCount(elementCount); + tracksInDatabase.Should().HaveCount(elementCount); for (int index = 0; index < elementCount; index++) { @@ -201,17 +198,17 @@ public async Task Can_create_resource_without_attributes_or_relationships() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("performers"); - resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().BeNull()); - resource.Attributes.ShouldContainKey("bornAt").With(value => value.Should().Be(default(DateTimeOffset))); + resource.Attributes.Should().ContainKey("artistName").WhoseValue.Should().BeNull(); + resource.Attributes.Should().ContainKey("bornAt").WhoseValue.Should().Be(default(DateTimeOffset)); resource.Relationships.Should().BeNull(); }); - int newPerformerId = int.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + int newPerformerId = int.Parse(responseDocument.Results[0].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -226,7 +223,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_create_resource_with_unknown_attribute() { // Arrange - string newName = _fakers.Playlist.Generate().Name; + string newName = _fakers.Playlist.GenerateOne().Name; var requestBody = new { @@ -256,15 +253,15 @@ public async Task Cannot_create_resource_with_unknown_attribute() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Unknown attribute found."); error.Detail.Should().Be("Attribute 'doesNotExist' does not exist on resource type 'playlists'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/doesNotExist"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -274,7 +271,7 @@ public async Task Can_create_resource_with_unknown_attribute() var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); options.AllowUnknownFieldsInRequestBody = true; - string newName = _fakers.Playlist.Generate().Name; + string newName = _fakers.Playlist.GenerateOne().Name; var requestBody = new { @@ -304,16 +301,16 @@ public async Task Can_create_resource_with_unknown_attribute() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("playlists"); - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newName)); - resource.Relationships.ShouldNotBeEmpty(); + resource.Attributes.Should().ContainKey("name").WhoseValue.Should().Be(newName); + resource.Relationships.Should().NotBeEmpty(); }); - long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -361,15 +358,15 @@ public async Task Cannot_create_resource_with_unknown_relationship() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Unknown relationship found."); error.Detail.Should().Be("Relationship 'doesNotExist' does not exist on resource type 'lyrics'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/doesNotExist"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -379,7 +376,7 @@ public async Task Can_create_resource_with_unknown_relationship() var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); options.AllowUnknownFieldsInRequestBody = true; - string newLyricText = _fakers.Lyric.Generate().Text; + string newLyricText = _fakers.Lyric.GenerateOne().Text; var requestBody = new { @@ -419,22 +416,22 @@ public async Task Can_create_resource_with_unknown_relationship() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("lyrics"); - resource.Attributes.ShouldNotBeEmpty(); - resource.Relationships.ShouldNotBeEmpty(); + resource.Attributes.Should().NotBeEmpty(); + resource.Relationships.Should().NotBeEmpty(); }); - long newLyricId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + long newLyricId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); await _testContext.RunOnDatabaseAsync(async dbContext => { Lyric lyricInDatabase = await dbContext.Lyrics.FirstWithIdAsync(newLyricId); - lyricInDatabase.ShouldNotBeNull(); + lyricInDatabase.Should().NotBeNull(); }); } @@ -442,7 +439,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_create_resource_with_client_generated_ID() { // Arrange - MusicTrack newTrack = _fakers.MusicTrack.Generate(); + MusicTrack newTrack = _fakers.MusicTrack.GenerateOne(); newTrack.Id = Guid.NewGuid(); var requestBody = new @@ -473,15 +470,15 @@ public async Task Cannot_create_resource_with_client_generated_ID() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Forbidden); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Forbidden); error.Title.Should().Be("Failed to deserialize request body: The use of client-generated IDs is disabled."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/id"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -495,7 +492,7 @@ public async Task Cannot_create_resource_for_href_element() new { op = "add", - href = "/api/v1/musicTracks" + href = "/api/musicTracks" } } }; @@ -508,15 +505,15 @@ public async Task Cannot_create_resource_for_href_element() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -546,15 +543,15 @@ public async Task Cannot_create_resource_for_ref_element() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'relationship' element is required."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -580,15 +577,15 @@ public async Task Cannot_create_resource_for_missing_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -615,22 +612,22 @@ public async Task Cannot_create_resource_for_null_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_create_resource_for_array_data() { // Arrange - string newArtistName = _fakers.Performer.Generate().ArtistName!; + string newArtistName = _fakers.Performer.GenerateOne().ArtistName!; var requestBody = new { @@ -662,15 +659,15 @@ public async Task Cannot_create_resource_for_array_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of an array."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -702,15 +699,15 @@ public async Task Cannot_create_resource_for_missing_type() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -740,64 +737,22 @@ public async Task Cannot_create_resource_for_unknown_type() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_create_resource_attribute_with_blocked_capability() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "lyrics", - attributes = new - { - createdAt = 12.July(1980) - } - } - } - } - }; - - 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: Attribute value cannot be assigned when creating resource."); - error.Detail.Should().Be("The attribute 'createdAt' on resource type 'lyrics' cannot be assigned to."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/createdAt"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_create_resource_with_readonly_attribute() { // Arrange - string newPlaylistName = _fakers.Playlist.Generate().Name; + string newPlaylistName = _fakers.Playlist.GenerateOne().Name; var requestBody = new { @@ -827,15 +782,15 @@ public async Task Cannot_create_resource_with_readonly_attribute() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Attribute is read-only."); error.Detail.Should().Be("Attribute 'isArchived' on resource type 'playlists' is read-only."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/isArchived"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -869,26 +824,26 @@ public async Task Cannot_create_resource_with_incompatible_attribute_value() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Incompatible attribute value found."); error.Detail.Should().Be("Failed to convert attribute 'bornAt' with value '12345' of type 'Number' to type 'DateTimeOffset'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/bornAt"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Can_create_resource_with_attributes_and_multiple_relationship_types() { // Arrange - Lyric existingLyric = _fakers.Lyric.Generate(); - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - Performer existingPerformer = _fakers.Performer.Generate(); + Lyric existingLyric = _fakers.Lyric.GenerateOne(); + RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); + Performer existingPerformer = _fakers.Performer.GenerateOne(); - string newTitle = _fakers.MusicTrack.Generate().Title; + string newTitle = _fakers.MusicTrack.GenerateOne().Title; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -953,21 +908,21 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("musicTracks"); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTitle)); - resource.Relationships.ShouldNotBeEmpty(); + resource.Attributes.Should().ContainKey("title").WhoseValue.Should().Be(newTitle); + resource.Relationships.Should().NotBeEmpty(); }); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); 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) @@ -975,19 +930,61 @@ 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); - trackInDatabase.Lyric.ShouldNotBeNull(); + trackInDatabase.Lyric.Should().NotBeNull(); trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); - trackInDatabase.OwnedBy.ShouldNotBeNull(); + trackInDatabase.OwnedBy.Should().NotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); - trackInDatabase.Performers.ShouldHaveCount(1); + trackInDatabase.Performers.Should().HaveCount(1); trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); }); } + + [Fact] + public async Task Cannot_assign_attribute_with_blocked_capability() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "lyrics", + attributes = new + { + createdAt = 12.July(1980) + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Attribute value cannot be assigned when creating resource."); + error.Detail.Should().Be("The attribute 'createdAt' on resource type 'lyrics' cannot be assigned to."); + error.Source.Should().NotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/createdAt"); + error.Meta.Should().HaveRequestBody(); + } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs index 62907c047a..0d8f7b14a7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs @@ -23,22 +23,24 @@ public AtomicCreateResourceWithClientGeneratedIdTests(IntegrationTestContext(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { - services.AddResourceDefinition(); + services.AddResourceDefinition(); services.AddSingleton(); }); - - var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); - options.AllowClientGeneratedIds = true; } - [Fact] - public async Task Can_create_resource_with_client_generated_guid_ID_having_side_effects() + [Theory] + [InlineData(ClientIdGenerationMode.Allowed)] + [InlineData(ClientIdGenerationMode.Required)] + public async Task Can_create_resource_with_client_generated_guid_ID_having_side_effects(ClientIdGenerationMode mode) { // Arrange - TextLanguage newLanguage = _fakers.TextLanguage.Generate(); + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.ClientIdGeneration = mode; + + TextLanguage newLanguage = _fakers.TextLanguage.GenerateOne(); newLanguage.Id = Guid.NewGuid(); var requestBody = new @@ -71,14 +73,14 @@ public async Task Can_create_resource_with_client_generated_guid_ID_having_side_ string isoCode = $"{newLanguage.IsoCode}{ImplicitlyChangingTextLanguageDefinition.Suffix}"; - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("textLanguages"); - resource.Attributes.ShouldContainKey("isoCode").With(value => value.Should().Be(isoCode)); + resource.Attributes.Should().ContainKey("isoCode").WhoseValue.Should().Be(isoCode); resource.Attributes.Should().NotContainKey("isRightToLeft"); - resource.Relationships.ShouldNotBeEmpty(); + resource.Relationships.Should().NotBeEmpty(); }); await _testContext.RunOnDatabaseAsync(async dbContext => @@ -89,11 +91,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - [Fact] - public async Task Can_create_resource_with_client_generated_string_ID_having_no_side_effects() + [Theory] + [InlineData(ClientIdGenerationMode.Allowed)] + [InlineData(ClientIdGenerationMode.Required)] + public async Task Can_create_resource_with_client_generated_guid_ID_having_no_side_effects(ClientIdGenerationMode mode) { // Arrange - MusicTrack newTrack = _fakers.MusicTrack.Generate(); + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.ClientIdGeneration = mode; + + MusicTrack newTrack = _fakers.MusicTrack.GenerateOne(); newTrack.Id = Guid.NewGuid(); var requestBody = new @@ -137,14 +144,125 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - [Fact] - public async Task Cannot_create_resource_for_existing_client_generated_ID() + [Theory] + [InlineData(ClientIdGenerationMode.Allowed)] + public async Task Can_create_resource_for_missing_client_generated_ID_having_side_effects(ClientIdGenerationMode mode) + { + // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.ClientIdGeneration = mode; + + string? newIsoCode = _fakers.TextLanguage.GenerateOne().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.OK); + + string isoCode = $"{newIsoCode}{ImplicitlyChangingTextLanguageDefinition.Suffix}"; + + responseDocument.Results.Should().HaveCount(1); + + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => + { + resource.Type.Should().Be("textLanguages"); + resource.Attributes.Should().ContainKey("isoCode").WhoseValue.Should().Be(isoCode); + resource.Relationships.Should().NotBeEmpty(); + }); + + Guid newLanguageId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + TextLanguage languageInDatabase = await dbContext.TextLanguages.FirstWithIdAsync(newLanguageId); + + languageInDatabase.IsoCode.Should().Be(isoCode); + }); + } + + [Theory] + [InlineData(ClientIdGenerationMode.Required)] + public async Task Cannot_create_resource_for_missing_client_generated_ID(ClientIdGenerationMode mode) + { + // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.ClientIdGeneration = mode; + + string? newIsoCode = _fakers.TextLanguage.GenerateOne().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.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' element is required."); + error.Detail.Should().BeNull(); + error.Source.Should().NotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); + error.Meta.Should().HaveRequestBody(); + } + + [Theory] + [InlineData(ClientIdGenerationMode.Allowed)] + [InlineData(ClientIdGenerationMode.Required)] + public async Task Cannot_create_resource_for_existing_client_generated_ID(ClientIdGenerationMode mode) { // Arrange - TextLanguage existingLanguage = _fakers.TextLanguage.Generate(); + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.ClientIdGeneration = mode; + + TextLanguage existingLanguage = _fakers.TextLanguage.GenerateOne(); existingLanguage.Id = Guid.NewGuid(); - TextLanguage languageToCreate = _fakers.TextLanguage.Generate(); + TextLanguage languageToCreate = _fakers.TextLanguage.GenerateOne(); languageToCreate.Id = existingLanguage.Id; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -181,21 +299,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Conflict); error.Title.Should().Be("Another resource with the specified ID already exists."); error.Detail.Should().Be($"Another resource of type 'textLanguages' with ID '{languageToCreate.StringId}' already exists."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); error.Meta.Should().NotContainKey("requestBody"); } - [Fact] - public async Task Cannot_create_resource_for_incompatible_ID() + [Theory] + [InlineData(ClientIdGenerationMode.Allowed)] + [InlineData(ClientIdGenerationMode.Required)] + public async Task Cannot_create_resource_for_incompatible_ID(ClientIdGenerationMode mode) { // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.ClientIdGeneration = mode; + string guid = Unknown.StringId.Guid; var requestBody = new @@ -225,21 +348,126 @@ public async Task Cannot_create_resource_for_incompatible_ID() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Incompatible 'id' value found."); error.Detail.Should().Be($"Failed to convert '{guid}' of type 'String' to type 'Int32'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/id"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); + } + + [Theory] + [InlineData(ClientIdGenerationMode.Allowed)] + public async Task Can_create_resource_with_local_ID(ClientIdGenerationMode mode) + { + // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.ClientIdGeneration = mode; + + string newTitle = _fakers.MusicTrack.GenerateOne().Title; + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + lid = "new-server-id", + attributes = new + { + title = newTitle + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(1); + + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Attributes.Should().ContainKey("title").WhoseValue.Should().Be(newTitle); + resource.Relationships.Should().BeNull(); + }); + + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack languageInDatabase = await dbContext.MusicTracks.FirstWithIdAsync(newTrackId); + + languageInDatabase.Title.Should().Be(newTitle); + }); + } + + [Theory] + [InlineData(ClientIdGenerationMode.Required)] + public async Task Cannot_create_resource_with_local_ID(ClientIdGenerationMode mode) + { + // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.ClientIdGeneration = mode; + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "textLanguages", + lid = "new-server-id" + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'lid' element cannot be used because a client-generated ID is required."); + error.Detail.Should().BeNull(); + error.Source.Should().NotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/lid"); + error.Meta.Should().HaveRequestBody(); } - [Fact] - public async Task Cannot_create_resource_for_ID_and_local_ID() + [Theory] + [InlineData(ClientIdGenerationMode.Allowed)] + [InlineData(ClientIdGenerationMode.Required)] + public async Task Cannot_create_resource_for_ID_and_local_ID(ClientIdGenerationMode mode) { // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.ClientIdGeneration = mode; + var requestBody = new { atomic__operations = new[] @@ -265,14 +493,14 @@ public async Task Cannot_create_resource_for_ID_and_local_ID() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs index be9700c3d6..0b86e91ddf 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs @@ -28,8 +28,8 @@ public AtomicCreateResourceWithToManyRelationshipTests(IntegrationTestContext existingPerformers = _fakers.Performer.Generate(2); - string newTitle = _fakers.MusicTrack.Generate().Title; + List existingPerformers = _fakers.Performer.GenerateList(2); + string newTitle = _fakers.MusicTrack.GenerateOne().Title; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -83,22 +83,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("musicTracks"); - resource.Attributes.ShouldNotBeEmpty(); - resource.Relationships.ShouldNotBeEmpty(); + resource.Attributes.Should().NotBeEmpty(); + resource.Relationships.Should().NotBeEmpty(); }); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(newTrackId); - trackInDatabase.Performers.ShouldHaveCount(2); + trackInDatabase.Performers.Should().HaveCount(2); trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[0].Id); trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[1].Id); }); @@ -108,8 +108,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_ManyToMany_relationship() { // Arrange - List existingTracks = _fakers.MusicTrack.Generate(3); - string newName = _fakers.Playlist.Generate().Name; + List existingTracks = _fakers.MusicTrack.GenerateList(3); + string newName = _fakers.Playlist.GenerateOne().Name; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -168,22 +168,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("playlists"); - resource.Attributes.ShouldNotBeEmpty(); - resource.Relationships.ShouldNotBeEmpty(); + resource.Attributes.Should().NotBeEmpty(); + resource.Relationships.Should().NotBeEmpty(); }); - long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); await _testContext.RunOnDatabaseAsync(async dbContext => { Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(newPlaylistId); - playlistInDatabase.Tracks.ShouldHaveCount(3); + playlistInDatabase.Tracks.Should().HaveCount(3); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[2].Id); @@ -230,15 +230,15 @@ public async Task Cannot_create_for_missing_relationship_type() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -282,15 +282,15 @@ public async Task Cannot_create_for_unknown_relationship_type() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -333,22 +333,22 @@ public async Task Cannot_create_for_missing_relationship_ID() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(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.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_create_for_unknown_relationship_IDs() { // Arrange - string newTitle = _fakers.MusicTrack.Generate().Title; + string newTitle = _fakers.MusicTrack.GenerateOne().Title; string performerId1 = Unknown.StringId.For(); string performerId2 = Unknown.StringId.AltFor(); @@ -399,13 +399,13 @@ public async Task Cannot_create_for_unknown_relationship_IDs() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(2); + responseDocument.Errors.Should().HaveCount(2); ErrorObject error1 = responseDocument.Errors[0]; error1.StatusCode.Should().Be(HttpStatusCode.NotFound); error1.Title.Should().Be("A related resource does not exist."); error1.Detail.Should().Be($"Related resource of type 'performers' with ID '{performerId1}' in relationship 'performers' does not exist."); - error1.Source.ShouldNotBeNull(); + error1.Source.Should().NotBeNull(); error1.Source.Pointer.Should().Be("/atomic:operations[0]"); error1.Meta.Should().NotContainKey("requestBody"); @@ -413,7 +413,7 @@ public async Task Cannot_create_for_unknown_relationship_IDs() error2.StatusCode.Should().Be(HttpStatusCode.NotFound); error2.Title.Should().Be("A related resource does not exist."); error2.Detail.Should().Be($"Related resource of type 'performers' with ID '{performerId2}' in relationship 'performers' does not exist."); - error2.Source.ShouldNotBeNull(); + error2.Source.Should().NotBeNull(); error2.Source.Pointer.Should().Be("/atomic:operations[0]"); error2.Meta.Should().NotContainKey("requestBody"); } @@ -459,23 +459,23 @@ public async Task Cannot_create_on_relationship_type_mismatch() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Conflict); error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); error.Detail.Should().Be("Type 'playlists' is not convertible to type 'performers' of relationship 'performers'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Can_create_with_duplicates() { // Arrange - Performer existingPerformer = _fakers.Performer.Generate(); - string newTitle = _fakers.MusicTrack.Generate().Title; + Performer existingPerformer = _fakers.Performer.GenerateOne(); + string newTitle = _fakers.MusicTrack.GenerateOne().Title; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -529,22 +529,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("musicTracks"); - resource.Attributes.ShouldNotBeEmpty(); - resource.Relationships.ShouldNotBeEmpty(); + resource.Attributes.Should().NotBeEmpty(); + resource.Relationships.Should().NotBeEmpty(); }); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(newTrackId); - trackInDatabase.Performers.ShouldHaveCount(1); + trackInDatabase.Performers.Should().HaveCount(1); trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); }); } @@ -582,15 +582,15 @@ public async Task Cannot_create_with_missing_data_in_OneToMany_relationship() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -627,15 +627,15 @@ public async Task Cannot_create_with_null_data_in_ManyToMany_relationship() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of 'null'."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/tracks/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -674,14 +674,66 @@ public async Task Cannot_create_with_object_data_in_ManyToMany_relationship() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of an object."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/tracks/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); + } + + [Fact] + public async Task Cannot_assign_relationship_with_blocked_capability() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + relationships = new + { + occursIn = new + { + data = new[] + { + new + { + type = "playlists", + id = Unknown.StringId.For() + } + } + } + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Relationship cannot be assigned."); + error.Detail.Should().Be("The relationship 'occursIn' on resource type 'musicTracks' cannot be assigned to."); + error.Source.Should().NotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/occursIn"); + error.Meta.Should().HaveRequestBody(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs index e7ba0d5288..a140a52322 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs @@ -29,9 +29,9 @@ public AtomicCreateResourceWithToOneRelationshipTests(IntegrationTestContext { @@ -77,22 +77,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("lyrics"); - resource.Attributes.ShouldNotBeEmpty(); - resource.Relationships.ShouldNotBeEmpty(); + resource.Attributes.Should().NotBeEmpty(); + resource.Relationships.Should().NotBeEmpty(); }); - long newLyricId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + long newLyricId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); await _testContext.RunOnDatabaseAsync(async dbContext => { Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(newLyricId); - lyricInDatabase.Track.ShouldNotBeNull(); + lyricInDatabase.Track.Should().NotBeNull(); lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); }); } @@ -101,8 +101,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_OneToOne_relationship_from_dependent_side() { // Arrange - Lyric existingLyric = _fakers.Lyric.Generate(); - string newTrackTitle = _fakers.MusicTrack.Generate().Title; + Lyric existingLyric = _fakers.Lyric.GenerateOne(); + string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -148,22 +148,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("musicTracks"); - resource.Attributes.ShouldNotBeEmpty(); - resource.Relationships.ShouldNotBeEmpty(); + resource.Attributes.Should().NotBeEmpty(); + resource.Relationships.Should().NotBeEmpty(); }); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(newTrackId); - trackInDatabase.Lyric.ShouldNotBeNull(); + trackInDatabase.Lyric.Should().NotBeNull(); trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); }); } @@ -174,8 +174,8 @@ public async Task Can_create_resources_with_ToOne_relationship() // Arrange const int elementCount = 5; - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - string[] newTrackTitles = _fakers.MusicTrack.Generate(elementCount).Select(musicTrack => musicTrack.Title).ToArray(); + RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); + string[] newTrackTitles = _fakers.MusicTrack.GenerateList(elementCount).Select(musicTrack => musicTrack.Title).ToArray(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -225,33 +225,33 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(elementCount); + responseDocument.Results.Should().HaveCount(elementCount); for (int index = 0; index < elementCount; index++) { - responseDocument.Results[index].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[index].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("musicTracks"); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitles[index])); + resource.Attributes.Should().ContainKey("title").WhoseValue.Should().Be(newTrackTitles[index]); }); } - Guid[] newTrackIds = responseDocument.Results.Select(result => Guid.Parse(result.Data.SingleValue!.Id.ShouldNotBeNull())).ToArray(); + Guid[] newTrackIds = responseDocument.Results.Select(result => Guid.Parse(result.Data.SingleValue!.Id.Should().NotBeNull().And.Subject)).ToArray(); 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); + tracksInDatabase.Should().HaveCount(elementCount); for (int index = 0; index < elementCount; index++) { @@ -259,7 +259,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Title.Should().Be(newTrackTitles[index]); - trackInDatabase.OwnedBy.ShouldNotBeNull(); + trackInDatabase.OwnedBy.Should().NotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); } }); @@ -296,15 +296,15 @@ public async Task Cannot_create_for_null_relationship() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -340,15 +340,15 @@ public async Task Cannot_create_for_missing_data_in_relationship() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -392,15 +392,15 @@ public async Task Cannot_create_for_array_data_in_relationship() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Expected an object or 'null', instead of an array."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -440,15 +440,15 @@ public async Task Cannot_create_for_missing_relationship_type() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -489,15 +489,15 @@ public async Task Cannot_create_for_unknown_relationship_type() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -537,22 +537,22 @@ public async Task Cannot_create_for_missing_relationship_ID() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(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.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_create_with_unknown_relationship_ID() { // Arrange - string newTrackTitle = _fakers.MusicTrack.Generate().Title; + string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; string lyricId = Unknown.StringId.For(); @@ -594,13 +594,13 @@ public async Task Cannot_create_with_unknown_relationship_ID() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); error.Title.Should().Be("A related resource does not exist."); error.Detail.Should().Be($"Related resource of type 'lyrics' with ID '{lyricId}' in relationship 'lyric' does not exist."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); error.Meta.Should().NotContainKey("requestBody"); } @@ -643,23 +643,23 @@ public async Task Cannot_create_on_relationship_type_mismatch() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Conflict); error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); error.Detail.Should().Be("Type 'playlists' is not convertible to type 'lyrics' of relationship 'lyric'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Can_create_resource_with_duplicate_relationship() { // Arrange - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - string newTrackTitle = _fakers.MusicTrack.Generate().Title; + RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); + string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -715,23 +715,72 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("musicTracks"); - resource.Attributes.ShouldNotBeEmpty(); - resource.Relationships.ShouldNotBeEmpty(); + resource.Attributes.Should().NotBeEmpty(); + resource.Relationships.Should().NotBeEmpty(); }); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(newTrackId); - trackInDatabase.OwnedBy.ShouldNotBeNull(); + trackInDatabase.OwnedBy.Should().NotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); }); } + + [Fact] + public async Task Cannot_assign_relationship_with_blocked_capability() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "lyrics", + relationships = new + { + language = new + { + data = new + { + type = "textLanguages", + id = Unknown.StringId.For() + } + } + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Relationship cannot be assigned."); + error.Detail.Should().Be("The relationship 'language' on resource type 'lyrics' cannot be assigned to."); + error.Source.Should().NotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/language"); + error.Meta.Should().HaveRequestBody(); + } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/DateMustBeInThePastAttribute.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/DateMustBeInThePastAttribute.cs new file mode 100644 index 0000000000..2a76f8ef34 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/DateMustBeInThePastAttribute.cs @@ -0,0 +1,35 @@ +using System.ComponentModel.DataAnnotations; +using System.Reflection; +using JsonApiDotNetCore.Resources; +using Microsoft.Extensions.DependencyInjection; + +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; + +[AttributeUsage(AttributeTargets.Property)] +internal sealed class DateMustBeInThePastAttribute : ValidationAttribute +{ + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + { + var targetedFields = validationContext.GetRequiredService(); + + if (targetedFields.Attributes.Any(attribute => attribute.Property.Name == validationContext.MemberName)) + { + PropertyInfo propertyInfo = validationContext.ObjectType.GetProperty(validationContext.MemberName!)!; + + if (propertyInfo.PropertyType == typeof(DateTimeOffset) || propertyInfo.PropertyType == typeof(DateTimeOffset?)) + { + var typedValue = (DateTimeOffset?)propertyInfo.GetValue(validationContext.ObjectInstance); + + var timeProvider = validationContext.GetRequiredService(); + DateTimeOffset utcNow = timeProvider.GetUtcNow(); + + if (typedValue >= utcNow) + { + return new ValidationResult($"{validationContext.MemberName} must be in the past."); + } + } + } + + return ValidationResult.Success; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs index 4baf6c7816..a330577dbd 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs @@ -23,7 +23,7 @@ public AtomicDeleteResourceTests(IntegrationTestContext { @@ -71,7 +71,7 @@ public async Task Can_delete_existing_resources() // Arrange const int elementCount = 5; - List existingTracks = _fakers.MusicTrack.Generate(elementCount); + List existingTracks = _fakers.MusicTrack.GenerateList(elementCount); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -122,8 +122,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_delete_resource_with_OneToOne_relationship_from_principal_side() { // Arrange - Lyric existingLyric = _fakers.Lyric.Generate(); - existingLyric.Track = _fakers.MusicTrack.Generate(); + Lyric existingLyric = _fakers.Lyric.GenerateOne(); + existingLyric.Track = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -173,8 +173,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_delete_resource_with_OneToOne_relationship_from_dependent_side() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Lyric = _fakers.Lyric.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + existingTrack.Lyric = _fakers.Lyric.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -224,8 +224,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_delete_existing_resource_with_OneToMany_relationship() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Performers = _fakers.Performer.Generate(2); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + existingTrack.Performers = _fakers.Performer.GenerateList(2); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -276,8 +276,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_delete_existing_resource_with_ManyToMany_relationship() { // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); - existingPlaylist.Tracks = _fakers.MusicTrack.Generate(1); + Playlist existingPlaylist = _fakers.Playlist.GenerateOne(); + existingPlaylist.Tracks = _fakers.MusicTrack.GenerateList(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -319,7 +319,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => MusicTrack? trackInDatabase = await dbContext.MusicTracks.FirstWithIdOrDefaultAsync(existingPlaylist.Tracks[0].Id); - trackInDatabase.ShouldNotBeNull(); + trackInDatabase.Should().NotBeNull(); }); } @@ -334,7 +334,7 @@ public async Task Cannot_delete_resource_for_href_element() new { op = "remove", - href = "/api/v1/musicTracks/1" + href = "/api/musicTracks/1" } } }; @@ -347,15 +347,15 @@ public async Task Cannot_delete_resource_for_href_element() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -381,15 +381,15 @@ public async Task Cannot_delete_resource_for_missing_ref_element() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'ref' element is required."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -419,15 +419,15 @@ public async Task Cannot_delete_resource_for_missing_type() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -458,15 +458,15 @@ public async Task Cannot_delete_resource_for_unknown_type() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -496,15 +496,15 @@ public async Task Cannot_delete_resource_for_missing_ID() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(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.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -537,13 +537,13 @@ public async Task Cannot_delete_resource_for_unknown_ID() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); error.Title.Should().Be("The requested resource does not exist."); error.Detail.Should().Be($"Resource of type 'performers' with ID '{performerId}' does not exist."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); error.Meta.Should().NotContainKey("requestBody"); } @@ -578,15 +578,15 @@ public async Task Cannot_delete_resource_for_incompatible_ID() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Incompatible 'id' value found."); error.Detail.Should().Be($"Failed to convert '{guid}' of type 'String' to type 'Int64'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/id"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -618,14 +618,14 @@ public async Task Cannot_delete_resource_for_ID_and_local_ID() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ImplicitlyChangingTextLanguageDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ImplicitlyChangingTextLanguageDefinition.cs index 4548e31c16..fedd490ddd 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ImplicitlyChangingTextLanguageDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ImplicitlyChangingTextLanguageDefinition.cs @@ -9,17 +9,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; /// Used to simulate side effects that occur in the database while saving, typically caused by database triggers. /// [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public class ImplicitlyChangingTextLanguageDefinition : HitCountingResourceDefinition +public class ImplicitlyChangingTextLanguageDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter, OperationsDbContext dbContext) + : HitCountingResourceDefinition(resourceGraph, hitCounter) { internal const string Suffix = " (changed)"; - private readonly OperationsDbContext _dbContext; - - public ImplicitlyChangingTextLanguageDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter, OperationsDbContext dbContext) - : base(resourceGraph, hitCounter) - { - _dbContext = dbContext; - } + private readonly OperationsDbContext _dbContext = dbContext; public override async Task OnWriteSucceededAsync(TextLanguage resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs index a6e162f72a..24423438bc 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs @@ -25,18 +25,15 @@ public AtomicAbsoluteLinksTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); + testContext.ConfigureServices(services => services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>))); } [Fact] public async Task Update_resource_with_side_effects_returns_absolute_links() { // Arrange - TextLanguage existingLanguage = _fakers.TextLanguage.Generate(); - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + TextLanguage existingLanguage = _fakers.TextLanguage.GenerateOne(); + RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -83,37 +80,37 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(2); + responseDocument.Results.Should().HaveCount(2); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { string languageLink = $"{HostPrefix}/textLanguages/{existingLanguage.StringId}"; - resource.ShouldNotBeNull(); - resource.Links.ShouldNotBeNull(); + resource.Should().NotBeNull(); + resource.Links.Should().NotBeNull(); resource.Links.Self.Should().Be(languageLink); - resource.Relationships.ShouldContainKey("lyrics").With(value => + resource.Relationships.Should().ContainKey("lyrics").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{languageLink}/relationships/lyrics"); value.Links.Related.Should().Be($"{languageLink}/lyrics"); }); }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[1].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { string companyLink = $"{HostPrefix}/recordCompanies/{existingCompany.StringId}"; - resource.ShouldNotBeNull(); - resource.Links.ShouldNotBeNull(); + resource.Should().NotBeNull(); + resource.Links.Should().NotBeNull(); resource.Links.Self.Should().Be(companyLink); - resource.Relationships.ShouldContainKey("tracks").With(value => + resource.Relationships.Should().ContainKey("tracks").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{companyLink}/relationships/tracks"); value.Links.Related.Should().Be($"{companyLink}/tracks"); }); @@ -124,7 +121,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Update_resource_with_side_effects_and_missing_resource_controller_hides_links() { // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); + Playlist existingPlaylist = _fakers.Playlist.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -159,11 +156,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { - resource.ShouldNotBeNull(); + resource.Should().NotBeNull(); resource.Links.Should().BeNull(); resource.Relationships.Should().BeNull(); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs index db6ee06bbf..6d5bd1797e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs @@ -26,17 +26,14 @@ public AtomicRelativeLinksWithNamespaceTests( testContext.UseController(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); + testContext.ConfigureServices(services => services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>))); } [Fact] public async Task Create_resource_with_side_effects_returns_relative_links() { // Arrange - string newCompanyName = _fakers.RecordCompany.Generate().Name; + string newCompanyName = _fakers.RecordCompany.GenerateOne().Name; var requestBody = new { @@ -76,39 +73,39 @@ public async Task Create_resource_with_side_effects_returns_relative_links() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(2); + responseDocument.Results.Should().HaveCount(2); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull(); + responseDocument.Results[0].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { - string languageLink = $"/api/textLanguages/{Guid.Parse(resource.Id.ShouldNotBeNull())}"; + string languageLink = $"/api/textLanguages/{Guid.Parse(resource.Id.Should().NotBeNull().And.Subject)}"; - resource.Links.ShouldNotBeNull(); + resource.Links.Should().NotBeNull(); resource.Links.Self.Should().Be(languageLink); - resource.Relationships.ShouldContainKey("lyrics").With(value => + resource.Relationships.Should().ContainKey("lyrics").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{languageLink}/relationships/lyrics"); value.Links.Related.Should().Be($"{languageLink}/lyrics"); }); }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull(); + responseDocument.Results[1].Data.SingleValue.Should().NotBeNull(); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[1].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { - string companyLink = $"/api/recordCompanies/{short.Parse(resource.Id.ShouldNotBeNull())}"; + string companyLink = $"/api/recordCompanies/{short.Parse(resource.Id.Should().NotBeNull().And.Subject)}"; - resource.Links.ShouldNotBeNull(); + resource.Links.Should().NotBeNull(); resource.Links.Self.Should().Be(companyLink); - resource.Relationships.ShouldContainKey("tracks").With(value => + resource.Relationships.Should().ContainKey("tracks").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{companyLink}/relationships/tracks"); value.Links.Related.Should().Be($"{companyLink}/tracks"); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs index 39768280bd..fb02b6df3f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs @@ -23,8 +23,8 @@ public AtomicLocalIdTests(IntegrationTestContext + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("recordCompanies"); resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newCompany.Name)); - resource.Attributes.ShouldContainKey("countryOfResidence").With(value => value.Should().Be(newCompany.CountryOfResidence)); + resource.Attributes.Should().ContainKey("name").WhoseValue.Should().Be(newCompany.Name); + resource.Attributes.Should().ContainKey("countryOfResidence").WhoseValue.Should().Be(newCompany.CountryOfResidence); }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[1].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("musicTracks"); resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + resource.Attributes.Should().ContainKey("title").WhoseValue.Should().Be(newTrackTitle); }); - short newCompanyId = short.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); + short newCompanyId = short.Parse(responseDocument.Results[0].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); + Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -106,7 +106,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.OwnedBy.ShouldNotBeNull(); + trackInDatabase.OwnedBy.Should().NotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(newCompanyId); trackInDatabase.OwnedBy.Name.Should().Be(newCompany.Name); trackInDatabase.OwnedBy.CountryOfResidence.Should().Be(newCompany.CountryOfResidence); @@ -117,8 +117,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_resource_with_OneToMany_relationship_using_local_ID() { // Arrange - Performer newPerformer = _fakers.Performer.Generate(); - string newTrackTitle = _fakers.MusicTrack.Generate().Title; + Performer newPerformer = _fakers.Performer.GenerateOne(); + string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; const string performerLocalId = "performer-1"; @@ -177,25 +177,25 @@ public async Task Can_create_resource_with_OneToMany_relationship_using_local_ID // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(2); + responseDocument.Results.Should().HaveCount(2); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("performers"); resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newPerformer.ArtistName)); - resource.Attributes.ShouldContainKey("bornAt").With(value => value.Should().Be(newPerformer.BornAt)); + resource.Attributes.Should().ContainKey("artistName").WhoseValue.Should().Be(newPerformer.ArtistName); + resource.Attributes.Should().ContainKey("bornAt").WhoseValue.Should().Be(newPerformer.BornAt); }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[1].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("musicTracks"); resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + resource.Attributes.Should().ContainKey("title").WhoseValue.Should().Be(newTrackTitle); }); - int newPerformerId = int.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); + int newPerformerId = int.Parse(responseDocument.Results[0].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); + Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -203,7 +203,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.Performers.ShouldHaveCount(1); + trackInDatabase.Performers.Should().HaveCount(1); trackInDatabase.Performers[0].Id.Should().Be(newPerformerId); trackInDatabase.Performers[0].ArtistName.Should().Be(newPerformer.ArtistName); trackInDatabase.Performers[0].BornAt.Should().Be(newPerformer.BornAt); @@ -214,8 +214,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_resource_with_ManyToMany_relationship_using_local_ID() { // Arrange - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newPlaylistName = _fakers.Playlist.Generate().Name; + string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; + string newPlaylistName = _fakers.Playlist.GenerateOne().Name; const string trackLocalId = "track-1"; @@ -273,24 +273,24 @@ public async Task Can_create_resource_with_ManyToMany_relationship_using_local_I // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(2); + responseDocument.Results.Should().HaveCount(2); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("musicTracks"); resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + resource.Attributes.Should().ContainKey("title").WhoseValue.Should().Be(newTrackTitle); }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[1].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("playlists"); resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newPlaylistName)); + resource.Attributes.Should().ContainKey("name").WhoseValue.Should().Be(newPlaylistName); }); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - long newPlaylistId = long.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); + long newPlaylistId = long.Parse(responseDocument.Results[1].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -298,7 +298,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => playlistInDatabase.Name.Should().Be(newPlaylistName); - playlistInDatabase.Tracks.ShouldHaveCount(1); + playlistInDatabase.Tracks.Should().HaveCount(1); playlistInDatabase.Tracks[0].Id.Should().Be(newTrackId); playlistInDatabase.Tracks[0].Title.Should().Be(newTrackTitle); }); @@ -310,7 +310,7 @@ public async Task Cannot_consume_local_ID_that_is_assigned_in_same_operation() // Arrange const string companyLocalId = "company-1"; - string newCompanyName = _fakers.RecordCompany.Generate().Name; + string newCompanyName = _fakers.RecordCompany.GenerateOne().Name; var requestBody = new { @@ -360,13 +360,13 @@ public async Task Cannot_consume_local_ID_that_is_assigned_in_same_operation() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Local ID cannot be both defined and used within the same operation."); error.Detail.Should().Be("Local ID 'company-1' cannot be both defined and used within the same operation."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[1]"); } @@ -374,7 +374,7 @@ public async Task Cannot_consume_local_ID_that_is_assigned_in_same_operation() public async Task Cannot_reassign_local_ID() { // Arrange - string newPlaylistName = _fakers.Playlist.Generate().Name; + string newPlaylistName = _fakers.Playlist.GenerateOne().Name; const string playlistLocalId = "playlist-1"; var requestBody = new @@ -427,13 +427,13 @@ public async Task Cannot_reassign_local_ID() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Another local ID with the same name is already defined at this point."); error.Detail.Should().Be("Another local ID with name 'playlist-1' is already defined at this point."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[2]"); } @@ -441,8 +441,8 @@ public async Task Cannot_reassign_local_ID() public async Task Can_update_resource_using_local_ID() { // Arrange - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newTrackGenre = _fakers.MusicTrack.Generate().Genre!; + string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; + string newTrackGenre = _fakers.MusicTrack.GenerateOne().Genre!; const string trackLocalId = "track-1"; @@ -487,19 +487,19 @@ public async Task Can_update_resource_using_local_ID() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(2); + responseDocument.Results.Should().HaveCount(2); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("musicTracks"); resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); - resource.Attributes.ShouldContainKey("genre").With(value => value.Should().BeNull()); + resource.Attributes.Should().ContainKey("title").WhoseValue.Should().Be(newTrackTitle); + resource.Attributes.Should().ContainKey("genre").WhoseValue.Should().BeNull(); }); responseDocument.Results[1].Data.Value.Should().BeNull(); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -514,9 +514,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_update_resource_with_relationships_using_local_ID() { // Arrange - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newArtistName = _fakers.Performer.Generate().ArtistName!; - string newCompanyName = _fakers.RecordCompany.Generate().Name; + string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; + string newArtistName = _fakers.Performer.GenerateOne().ArtistName!; + string newCompanyName = _fakers.RecordCompany.GenerateOne().Name; const string trackLocalId = "track-1"; const string performerLocalId = "performer-1"; @@ -607,54 +607,54 @@ public async Task Can_update_resource_with_relationships_using_local_ID() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(4); + responseDocument.Results.Should().HaveCount(4); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("musicTracks"); resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + resource.Attributes.Should().ContainKey("title").WhoseValue.Should().Be(newTrackTitle); }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[1].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("performers"); resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName)); + resource.Attributes.Should().ContainKey("artistName").WhoseValue.Should().Be(newArtistName); }); - responseDocument.Results[2].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[2].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("recordCompanies"); resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newCompanyName)); + resource.Attributes.Should().ContainKey("name").WhoseValue.Should().Be(newCompanyName); }); responseDocument.Results[3].Data.Value.Should().BeNull(); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); - short newCompanyId = short.Parse(responseDocument.Results[2].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); + int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); + short newCompanyId = short.Parse(responseDocument.Results[2].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); 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); - trackInDatabase.OwnedBy.ShouldNotBeNull(); + trackInDatabase.OwnedBy.Should().NotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(newCompanyId); - trackInDatabase.Performers.ShouldHaveCount(1); + trackInDatabase.Performers.Should().HaveCount(1); trackInDatabase.Performers[0].Id.Should().Be(newPerformerId); trackInDatabase.Performers[0].ArtistName.Should().Be(newArtistName); }); @@ -664,8 +664,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_ManyToOne_relationship_using_local_ID() { // Arrange - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newCompanyName = _fakers.RecordCompany.Generate().Name; + string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; + string newCompanyName = _fakers.RecordCompany.GenerateOne().Name; const string trackLocalId = "track-1"; const string companyLocalId = "company-1"; @@ -726,26 +726,26 @@ public async Task Can_create_ManyToOne_relationship_using_local_ID() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(3); + responseDocument.Results.Should().HaveCount(3); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("musicTracks"); resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + resource.Attributes.Should().ContainKey("title").WhoseValue.Should().Be(newTrackTitle); }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[1].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("recordCompanies"); resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newCompanyName)); + resource.Attributes.Should().ContainKey("name").WhoseValue.Should().Be(newCompanyName); }); responseDocument.Results[2].Data.Value.Should().BeNull(); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - short newCompanyId = short.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); + short newCompanyId = short.Parse(responseDocument.Results[1].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -753,7 +753,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.OwnedBy.ShouldNotBeNull(); + trackInDatabase.OwnedBy.Should().NotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(newCompanyId); trackInDatabase.OwnedBy.Name.Should().Be(newCompanyName); }); @@ -763,8 +763,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_OneToMany_relationship_using_local_ID() { // Arrange - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newArtistName = _fakers.Performer.Generate().ArtistName!; + string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; + string newArtistName = _fakers.Performer.GenerateOne().ArtistName!; const string trackLocalId = "track-1"; const string performerLocalId = "performer-1"; @@ -828,26 +828,26 @@ public async Task Can_create_OneToMany_relationship_using_local_ID() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(3); + responseDocument.Results.Should().HaveCount(3); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("musicTracks"); resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + resource.Attributes.Should().ContainKey("title").WhoseValue.Should().Be(newTrackTitle); }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[1].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("performers"); resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName)); + resource.Attributes.Should().ContainKey("artistName").WhoseValue.Should().Be(newArtistName); }); responseDocument.Results[2].Data.Value.Should().BeNull(); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); + int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -855,7 +855,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.Performers.ShouldHaveCount(1); + trackInDatabase.Performers.Should().HaveCount(1); trackInDatabase.Performers[0].Id.Should().Be(newPerformerId); trackInDatabase.Performers[0].ArtistName.Should().Be(newArtistName); }); @@ -865,8 +865,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_ManyToMany_relationship_using_local_ID() { // Arrange - string newPlaylistName = _fakers.Playlist.Generate().Name; - string newTrackTitle = _fakers.MusicTrack.Generate().Title; + string newPlaylistName = _fakers.Playlist.GenerateOne().Name; + string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; const string playlistLocalId = "playlist-1"; const string trackLocalId = "track-1"; @@ -930,26 +930,26 @@ public async Task Can_create_ManyToMany_relationship_using_local_ID() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(3); + responseDocument.Results.Should().HaveCount(3); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("playlists"); resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newPlaylistName)); + resource.Attributes.Should().ContainKey("name").WhoseValue.Should().Be(newPlaylistName); }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[1].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("musicTracks"); resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + resource.Attributes.Should().ContainKey("title").WhoseValue.Should().Be(newTrackTitle); }); responseDocument.Results[2].Data.Value.Should().BeNull(); - long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); + long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); + Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -957,7 +957,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => playlistInDatabase.Name.Should().Be(newPlaylistName); - playlistInDatabase.Tracks.ShouldHaveCount(1); + playlistInDatabase.Tracks.Should().HaveCount(1); playlistInDatabase.Tracks[0].Id.Should().Be(newTrackId); playlistInDatabase.Tracks[0].Title.Should().Be(newTrackTitle); }); @@ -967,10 +967,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_replace_OneToMany_relationship_using_local_ID() { // Arrange - Performer existingPerformer = _fakers.Performer.Generate(); + Performer existingPerformer = _fakers.Performer.GenerateOne(); - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newArtistName = _fakers.Performer.Generate().ArtistName!; + string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; + string newArtistName = _fakers.Performer.GenerateOne().ArtistName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1054,26 +1054,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(3); + responseDocument.Results.Should().HaveCount(3); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("musicTracks"); resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + resource.Attributes.Should().ContainKey("title").WhoseValue.Should().Be(newTrackTitle); }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[1].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("performers"); resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName)); + resource.Attributes.Should().ContainKey("artistName").WhoseValue.Should().Be(newArtistName); }); responseDocument.Results[2].Data.Value.Should().BeNull(); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); + int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1081,7 +1081,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.Performers.ShouldHaveCount(1); + trackInDatabase.Performers.Should().HaveCount(1); trackInDatabase.Performers[0].Id.Should().Be(newPerformerId); trackInDatabase.Performers[0].ArtistName.Should().Be(newArtistName); }); @@ -1091,10 +1091,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_replace_ManyToMany_relationship_using_local_ID() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); - string newPlaylistName = _fakers.Playlist.Generate().Name; - string newTrackTitle = _fakers.MusicTrack.Generate().Title; + string newPlaylistName = _fakers.Playlist.GenerateOne().Name; + string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1178,26 +1178,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(3); + responseDocument.Results.Should().HaveCount(3); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("playlists"); resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newPlaylistName)); + resource.Attributes.Should().ContainKey("name").WhoseValue.Should().Be(newPlaylistName); }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[1].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("musicTracks"); resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + resource.Attributes.Should().ContainKey("title").WhoseValue.Should().Be(newTrackTitle); }); responseDocument.Results[2].Data.Value.Should().BeNull(); - long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); + long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); + Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1205,7 +1205,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => playlistInDatabase.Name.Should().Be(newPlaylistName); - playlistInDatabase.Tracks.ShouldHaveCount(1); + playlistInDatabase.Tracks.Should().HaveCount(1); playlistInDatabase.Tracks[0].Id.Should().Be(newTrackId); playlistInDatabase.Tracks[0].Title.Should().Be(newTrackTitle); }); @@ -1215,10 +1215,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_add_to_OneToMany_relationship_using_local_ID() { // Arrange - Performer existingPerformer = _fakers.Performer.Generate(); + Performer existingPerformer = _fakers.Performer.GenerateOne(); - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newArtistName = _fakers.Performer.Generate().ArtistName!; + string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; + string newArtistName = _fakers.Performer.GenerateOne().ArtistName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1302,26 +1302,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(3); + responseDocument.Results.Should().HaveCount(3); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("musicTracks"); resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + resource.Attributes.Should().ContainKey("title").WhoseValue.Should().Be(newTrackTitle); }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[1].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("performers"); resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName)); + resource.Attributes.Should().ContainKey("artistName").WhoseValue.Should().Be(newArtistName); }); responseDocument.Results[2].Data.Value.Should().BeNull(); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); + int newPerformerId = int.Parse(responseDocument.Results[1].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1329,7 +1329,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.Performers.ShouldHaveCount(2); + trackInDatabase.Performers.Should().HaveCount(2); trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); trackInDatabase.Performers[0].ArtistName.Should().Be(existingPerformer.ArtistName); @@ -1343,10 +1343,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_add_to_ManyToMany_relationship_using_local_ID() { // Arrange - List existingTracks = _fakers.MusicTrack.Generate(2); + List existingTracks = _fakers.MusicTrack.GenerateList(2); - string newPlaylistName = _fakers.Playlist.Generate().Name; - string newTrackTitle = _fakers.MusicTrack.Generate().Title; + string newPlaylistName = _fakers.Playlist.GenerateOne().Name; + string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; const string playlistLocalId = "playlist-1"; const string trackLocalId = "track-1"; @@ -1448,28 +1448,28 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(4); + responseDocument.Results.Should().HaveCount(4); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("playlists"); resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newPlaylistName)); + resource.Attributes.Should().ContainKey("name").WhoseValue.Should().Be(newPlaylistName); }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[1].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("musicTracks"); resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + resource.Attributes.Should().ContainKey("title").WhoseValue.Should().Be(newTrackTitle); }); responseDocument.Results[2].Data.Value.Should().BeNull(); responseDocument.Results[3].Data.Value.Should().BeNull(); - long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); - Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.ShouldNotBeNull()); + long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); + Guid newTrackId = Guid.Parse(responseDocument.Results[1].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1477,7 +1477,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => playlistInDatabase.Name.Should().Be(newPlaylistName); - playlistInDatabase.Tracks.ShouldHaveCount(3); + playlistInDatabase.Tracks.Should().HaveCount(3); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == newTrackId); @@ -1488,11 +1488,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_remove_from_OneToMany_relationship_using_local_ID() { // Arrange - Performer existingPerformer = _fakers.Performer.Generate(); + Performer existingPerformer = _fakers.Performer.GenerateOne(); - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newArtistName1 = _fakers.Performer.Generate().ArtistName!; - string newArtistName2 = _fakers.Performer.Generate().ArtistName!; + string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; + string newArtistName1 = _fakers.Performer.GenerateOne().ArtistName!; + string newArtistName2 = _fakers.Performer.GenerateOne().ArtistName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1605,32 +1605,32 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(4); + responseDocument.Results.Should().HaveCount(4); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("performers"); resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName1)); + resource.Attributes.Should().ContainKey("artistName").WhoseValue.Should().Be(newArtistName1); }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[1].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("performers"); resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("artistName").With(value => value.Should().Be(newArtistName2)); + resource.Attributes.Should().ContainKey("artistName").WhoseValue.Should().Be(newArtistName2); }); - responseDocument.Results[2].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[2].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("musicTracks"); resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + resource.Attributes.Should().ContainKey("title").WhoseValue.Should().Be(newTrackTitle); }); responseDocument.Results[3].Data.Value.Should().BeNull(); - Guid newTrackId = Guid.Parse(responseDocument.Results[2].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[2].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1638,7 +1638,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.Performers.ShouldHaveCount(1); + trackInDatabase.Performers.Should().HaveCount(1); trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); trackInDatabase.Performers[0].ArtistName.Should().Be(existingPerformer.ArtistName); }); @@ -1648,10 +1648,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_remove_from_ManyToMany_relationship_using_local_ID() { // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); - existingPlaylist.Tracks = _fakers.MusicTrack.Generate(2); + Playlist existingPlaylist = _fakers.Playlist.GenerateOne(); + existingPlaylist.Tracks = _fakers.MusicTrack.GenerateList(2); - string newTrackTitle = _fakers.MusicTrack.Generate().Title; + string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; const string trackLocalId = "track-1"; @@ -1743,13 +1743,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(4); + responseDocument.Results.Should().HaveCount(4); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("musicTracks"); resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + resource.Attributes.Should().ContainKey("title").WhoseValue.Should().Be(newTrackTitle); }); responseDocument.Results[1].Data.Value.Should().BeNull(); @@ -1762,7 +1762,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - playlistInDatabase.Tracks.ShouldHaveCount(1); + playlistInDatabase.Tracks.Should().HaveCount(1); playlistInDatabase.Tracks[0].Id.Should().Be(existingPlaylist.Tracks[0].Id); }); } @@ -1771,7 +1771,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_delete_resource_using_local_ID() { // Arrange - string newTrackTitle = _fakers.MusicTrack.Generate().Title; + string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; const string trackLocalId = "track-1"; @@ -1812,18 +1812,18 @@ public async Task Can_delete_resource_using_local_ID() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(2); + responseDocument.Results.Should().HaveCount(2); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("musicTracks"); resource.Lid.Should().BeNull(); - resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTrackTitle)); + resource.Attributes.Should().ContainKey("title").WhoseValue.Should().Be(newTrackTitle); }); responseDocument.Results[1].Data.Value.Should().BeNull(); - Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.Should().NotBeNull().And.Subject); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1870,13 +1870,13 @@ public async Task Cannot_consume_unassigned_local_ID_in_ref() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Server-generated value for local ID is not available at this point."); error.Detail.Should().Be($"Server-generated value for local ID '{Unknown.LocalId}' is not available at this point."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[1]"); } @@ -1920,13 +1920,13 @@ public async Task Cannot_consume_unassigned_local_ID_in_data_element() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Server-generated value for local ID is not available at this point."); error.Detail.Should().Be($"Server-generated value for local ID '{Unknown.LocalId}' is not available at this point."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[1]"); } @@ -1934,7 +1934,7 @@ public async Task Cannot_consume_unassigned_local_ID_in_data_element() public async Task Cannot_consume_unassigned_local_ID_in_data_array() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1984,13 +1984,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Server-generated value for local ID is not available at this point."); error.Detail.Should().Be($"Server-generated value for local ID '{Unknown.LocalId}' is not available at this point."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[1]"); } @@ -1998,7 +1998,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_element() { // Arrange - string newTrackTitle = _fakers.MusicTrack.Generate().Title; + string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; var requestBody = new { @@ -2047,13 +2047,13 @@ public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_elemen // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Server-generated value for local ID is not available at this point."); error.Detail.Should().Be($"Server-generated value for local ID '{Unknown.LocalId}' is not available at this point."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[1]"); } @@ -2061,7 +2061,7 @@ public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_elemen public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_array() { // Arrange - string newPlaylistName = _fakers.Playlist.Generate().Name; + string newPlaylistName = _fakers.Playlist.GenerateOne().Name; var requestBody = new { @@ -2113,13 +2113,13 @@ public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_array( // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Server-generated value for local ID is not available at this point."); error.Detail.Should().Be($"Server-generated value for local ID '{Unknown.LocalId}' is not available at this point."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[1]"); } @@ -2128,7 +2128,7 @@ public async Task Cannot_consume_local_ID_of_different_type_in_same_operation() { // Arrange const string trackLocalId = "track-1"; - string newTrackTitle = _fakers.MusicTrack.Generate().Title; + string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; var requestBody = new { @@ -2178,13 +2178,13 @@ public async Task Cannot_consume_local_ID_of_different_type_in_same_operation() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Incompatible type in Local ID usage."); error.Detail.Should().Be("Local ID 'track-1' belongs to resource type 'musicTracks' instead of 'recordCompanies'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[1]"); } @@ -2194,7 +2194,7 @@ public async Task Cannot_consume_local_ID_of_different_type_in_ref() // Arrange const string companyLocalId = "company-1"; - string newCompanyName = _fakers.RecordCompany.Generate().Name; + string newCompanyName = _fakers.RecordCompany.GenerateOne().Name; var requestBody = new { @@ -2242,13 +2242,13 @@ public async Task Cannot_consume_local_ID_of_different_type_in_ref() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Incompatible type in Local ID usage."); error.Detail.Should().Be("Local ID 'company-1' belongs to resource type 'recordCompanies' instead of 'musicTracks'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[2]"); } @@ -2303,13 +2303,13 @@ public async Task Cannot_consume_local_ID_of_different_type_in_data_element() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Incompatible type in Local ID usage."); error.Detail.Should().Be("Local ID 'performer-1' belongs to resource type 'performers' instead of 'playlists'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[2]"); } @@ -2317,11 +2317,11 @@ public async Task Cannot_consume_local_ID_of_different_type_in_data_element() public async Task Cannot_consume_local_ID_of_different_type_in_data_array() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); const string companyLocalId = "company-1"; - string newCompanyName = _fakers.RecordCompany.Generate().Name; + string newCompanyName = _fakers.RecordCompany.GenerateOne().Name; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -2384,13 +2384,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Incompatible type in Local ID usage."); error.Detail.Should().Be("Local ID 'company-1' belongs to resource type 'recordCompanies' instead of 'performers'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[2]"); } @@ -2398,8 +2398,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_consume_local_ID_of_different_type_in_relationship_data_element() { // Arrange - string newPlaylistName = _fakers.Playlist.Generate().Name; - string newTrackTitle = _fakers.MusicTrack.Generate().Title; + string newPlaylistName = _fakers.Playlist.GenerateOne().Name; + string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; const string playlistLocalId = "playlist-1"; @@ -2463,13 +2463,13 @@ public async Task Cannot_consume_local_ID_of_different_type_in_relationship_data // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Incompatible type in Local ID usage."); error.Detail.Should().Be("Local ID 'playlist-1' belongs to resource type 'playlists' instead of 'recordCompanies'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[2]"); } @@ -2478,7 +2478,7 @@ public async Task Cannot_consume_local_ID_of_different_type_in_relationship_data { // Arrange const string performerLocalId = "performer-1"; - string newPlaylistName = _fakers.Playlist.Generate().Name; + string newPlaylistName = _fakers.Playlist.GenerateOne().Name; var requestBody = new { @@ -2539,13 +2539,13 @@ public async Task Cannot_consume_local_ID_of_different_type_in_relationship_data // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Incompatible type in Local ID usage."); error.Detail.Should().Be("Local ID 'performer-1' belongs to resource type 'performers' instead of 'musicTracks'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[2]"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Lyric.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Lyric.cs index 2baa9ac431..af1ac9e18b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Lyric.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Lyric.cs @@ -17,7 +17,7 @@ public sealed class Lyric : Identifiable [Attr(Capabilities = AttrCapabilities.None)] public DateTimeOffset CreatedAt { get; set; } - [HasOne] + [HasOne(Capabilities = HasOneCapabilities.All & ~HasOneCapabilities.AllowSet)] public TextLanguage? Language { get; set; } [HasOne] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs index 12875231b6..1ab1d848ac 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs @@ -21,7 +21,7 @@ public AtomicResourceMetaTests(IntegrationTestContext(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceDefinition(); services.AddResourceDefinition(); @@ -39,8 +39,8 @@ public async Task Returns_resource_meta_in_create_resource_with_side_effects() // Arrange var hitCounter = _testContext.Factory.Services.GetRequiredService(); - string newTitle1 = _fakers.MusicTrack.Generate().Title; - string newTitle2 = _fakers.MusicTrack.Generate().Title; + string newTitle1 = _fakers.MusicTrack.GenerateOne().Title; + string newTitle2 = _fakers.MusicTrack.GenerateOne().Title; var requestBody = new { @@ -83,24 +83,24 @@ public async Task Returns_resource_meta_in_create_resource_with_side_effects() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(2); + responseDocument.Results.Should().HaveCount(2); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { - resource.Meta.ShouldHaveCount(1); + resource.Meta.Should().HaveCount(1); - resource.Meta.ShouldContainKey("copyright").With(value => + resource.Meta.Should().ContainKey("copyright").WhoseValue.With(value => { JsonElement element = value.Should().BeOfType().Subject; element.GetString().Should().Be("(C) 2018. All rights reserved."); }); }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[1].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { - resource.Meta.ShouldHaveCount(1); + resource.Meta.Should().HaveCount(1); - resource.Meta.ShouldContainKey("copyright").With(value => + resource.Meta.Should().ContainKey("copyright").WhoseValue.With(value => { JsonElement element = value.Should().BeOfType().Subject; element.GetString().Should().Be("(C) 1994. All rights reserved."); @@ -120,7 +120,7 @@ public async Task Returns_resource_meta_in_update_resource_with_side_effects() // Arrange var hitCounter = _testContext.Factory.Services.GetRequiredService(); - TextLanguage existingLanguage = _fakers.TextLanguage.Generate(); + TextLanguage existingLanguage = _fakers.TextLanguage.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -155,13 +155,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { - resource.Meta.ShouldHaveCount(1); + resource.Meta.Should().HaveCount(1); - resource.Meta.ShouldContainKey("notice").With(value => + resource.Meta.Should().ContainKey("notice").WhoseValue.With(value => { JsonElement element = value.Should().BeOfType().Subject; element.GetString().Should().Be(TextLanguageMetaDefinition.NoticeText); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs index ab084a0e90..b111ee47fb 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs @@ -21,7 +21,7 @@ public AtomicResponseMetaTests(IntegrationTestContext(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceDefinition(); @@ -60,26 +60,26 @@ public async Task Returns_top_level_meta_in_create_resource_with_side_effects() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Meta.ShouldHaveCount(3); + responseDocument.Meta.Should().HaveCount(3); - responseDocument.Meta.ShouldContainKey("license").With(value => + responseDocument.Meta.Should().ContainKey("license").WhoseValue.With(value => { JsonElement element = value.Should().BeOfType().Subject; element.GetString().Should().Be("MIT"); }); - responseDocument.Meta.ShouldContainKey("projectUrl").With(value => + responseDocument.Meta.Should().ContainKey("projectUrl").WhoseValue.With(value => { JsonElement element = value.Should().BeOfType().Subject; element.GetString().Should().Be("https://github.com/json-api-dotnet/JsonApiDotNetCore/"); }); - responseDocument.Meta.ShouldContainKey("versions").With(value => + responseDocument.Meta.Should().ContainKey("versions").WhoseValue.With(value => { JsonElement element = value.Should().BeOfType().Subject; string?[] versionArray = element.EnumerateArray().Select(arrayItem => arrayItem.GetString()).ToArray(); - versionArray.ShouldHaveCount(4); + versionArray.Should().HaveCount(4); versionArray.Should().Contain("v4.0.0"); versionArray.Should().Contain("v3.1.0"); versionArray.Should().Contain("v2.5.2"); @@ -91,7 +91,7 @@ public async Task Returns_top_level_meta_in_create_resource_with_side_effects() public async Task Returns_top_level_meta_in_update_resource_with_side_effects() { // Arrange - TextLanguage existingLanguage = _fakers.TextLanguage.Generate(); + TextLanguage existingLanguage = _fakers.TextLanguage.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -126,26 +126,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Meta.ShouldHaveCount(3); + responseDocument.Meta.Should().HaveCount(3); - responseDocument.Meta.ShouldContainKey("license").With(value => + responseDocument.Meta.Should().ContainKey("license").WhoseValue.With(value => { JsonElement element = value.Should().BeOfType().Subject; element.GetString().Should().Be("MIT"); }); - responseDocument.Meta.ShouldContainKey("projectUrl").With(value => + responseDocument.Meta.Should().ContainKey("projectUrl").WhoseValue.With(value => { JsonElement element = value.Should().BeOfType().Subject; element.GetString().Should().Be("https://github.com/json-api-dotnet/JsonApiDotNetCore/"); }); - responseDocument.Meta.ShouldContainKey("versions").With(value => + responseDocument.Meta.Should().ContainKey("versions").WhoseValue.With(value => { JsonElement element = value.Should().BeOfType().Subject; string?[] versionArray = element.EnumerateArray().Select(arrayItem => arrayItem.GetString()).ToArray(); - versionArray.ShouldHaveCount(4); + versionArray.Should().HaveCount(4); versionArray.Should().Contain("v4.0.0"); versionArray.Should().Contain("v3.1.0"); versionArray.Should().Contain("v2.5.2"); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/MusicTrackMetaDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/MusicTrackMetaDefinition.cs index be59668c1c..3c8dda12ab 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/MusicTrackMetaDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/MusicTrackMetaDefinition.cs @@ -4,15 +4,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Meta; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class MusicTrackMetaDefinition : HitCountingResourceDefinition +public sealed class MusicTrackMetaDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter) + : HitCountingResourceDefinition(resourceGraph, hitCounter) { protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.GetMeta; - public MusicTrackMetaDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph, hitCounter) - { - } - public override IDictionary GetMeta(MusicTrack resource) { base.GetMeta(resource); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/TextLanguageMetaDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/TextLanguageMetaDefinition.cs index f46b2940a6..679a3b76a9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/TextLanguageMetaDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Meta/TextLanguageMetaDefinition.cs @@ -4,17 +4,13 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Meta; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class TextLanguageMetaDefinition : ImplicitlyChangingTextLanguageDefinition +public sealed class TextLanguageMetaDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter, OperationsDbContext dbContext) + : ImplicitlyChangingTextLanguageDefinition(resourceGraph, hitCounter, dbContext) { internal const string NoticeText = "See https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes for ISO 639-1 language codes."; protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.GetMeta; - public TextLanguageMetaDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter, OperationsDbContext dbContext) - : base(resourceGraph, hitCounter, dbContext) - { - } - public override IDictionary GetMeta(TextLanguage resource) { base.GetMeta(resource); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs index f871e90238..1662f9387f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicLoggingTests.cs @@ -19,32 +19,24 @@ public AtomicLoggingTests(IntegrationTestContext(); - var loggerFactory = new FakeLoggerFactory(LogLevel.Information); - testContext.ConfigureLogging(options => { - options.ClearProviders(); - options.AddProvider(loggerFactory); + var loggerProvider = new CapturingLoggerProvider(LogLevel.Information); + options.AddProvider(loggerProvider); options.SetMinimumLevel(LogLevel.Information); - }); - testContext.ConfigureServicesBeforeStartup(services => - { - services.AddSingleton(loggerFactory); + options.Services.AddSingleton(loggerProvider); }); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddSingleton(); - }); + testContext.ConfigureServices(services => services.AddSingleton()); } [Fact] - public async Task Logs_at_error_level_on_unhandled_exception() + public async Task Logs_unhandled_exception_at_Error_level() { // Arrange - var loggerFactory = _testContext.Factory.Services.GetRequiredService(); - loggerFactory.Logger.Clear(); + var loggerProvider = _testContext.Factory.Services.GetRequiredService(); + loggerProvider.Clear(); var transactionFactory = (ThrowingOperationsTransactionFactory)_testContext.Factory.Services.GetRequiredService(); transactionFactory.ThrowOnOperationStart = true; @@ -75,27 +67,27 @@ public async Task Logs_at_error_level_on_unhandled_exception() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.InternalServerError); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.InternalServerError); error.Title.Should().Be("An unhandled error occurred while processing an operation in this request."); error.Detail.Should().Be("Simulated failure."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); - loggerFactory.Logger.Messages.ShouldNotBeEmpty(); + IReadOnlyList logMessages = loggerProvider.GetMessages(); - loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Error && - message.Text.Contains("Simulated failure.", StringComparison.Ordinal)); + logMessages.Should().ContainSingle(message => + message.LogLevel == LogLevel.Error && message.Text.Contains("Simulated failure.", StringComparison.Ordinal)); } [Fact] - public async Task Logs_at_info_level_on_invalid_request_body() + public async Task Logs_invalid_request_body_error_at_Information_level() { // Arrange - var loggerFactory = _testContext.Factory.Services.GetRequiredService(); - loggerFactory.Logger.Clear(); + var loggerProvider = _testContext.Factory.Services.GetRequiredService(); + loggerProvider.Clear(); var transactionFactory = (ThrowingOperationsTransactionFactory)_testContext.Factory.Services.GetRequiredService(); transactionFactory.ThrowOnOperationStart = false; @@ -119,11 +111,11 @@ public async Task Logs_at_info_level_on_invalid_request_body() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); - loggerFactory.Logger.Messages.ShouldNotBeEmpty(); + IReadOnlyList logMessages = loggerProvider.GetMessages(); - loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Information && + logMessages.Should().ContainSingle(message => message.LogLevel == LogLevel.Information && message.Text.Contains("Failed to deserialize request body", StringComparison.Ordinal)); } @@ -137,17 +129,12 @@ public Task BeginTransactionAsync(CancellationToken canc return Task.FromResult(transaction); } - private sealed class ThrowingOperationsTransaction : IOperationsTransaction + private sealed class ThrowingOperationsTransaction(ThrowingOperationsTransactionFactory owner) : IOperationsTransaction { - private readonly ThrowingOperationsTransactionFactory _owner; + private readonly ThrowingOperationsTransactionFactory _owner = owner; public string TransactionId => "some"; - public ThrowingOperationsTransaction(ThrowingOperationsTransactionFactory owner) - { - _owner = owner; - } - public ValueTask DisposeAsync() { return ValueTask.CompletedTask; @@ -172,7 +159,7 @@ private Task ThrowIfEnabled() { if (_owner.ThrowOnOperationStart) { - throw new Exception("Simulated failure."); + throw new InvalidOperationException("Simulated failure."); } return Task.CompletedTask; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs index ee9d144123..abc4525b60 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs @@ -1,5 +1,6 @@ using System.Net; using FluentAssertions; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Serialization.Objects; using TestBuildingBlocks; using Xunit; @@ -29,7 +30,10 @@ public async Task Cannot_process_for_missing_request_body() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.AtomicOperations.ToString()); + + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); @@ -53,14 +57,14 @@ public async Task Cannot_process_for_null_request_body() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); error.Detail.Should().BeNull(); error.Source.Should().BeNull(); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -77,7 +81,7 @@ public async Task Cannot_process_for_broken_JSON_request_body() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); @@ -106,14 +110,14 @@ public async Task Cannot_process_for_missing_operations_array() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: No operations found."); error.Detail.Should().BeNull(); error.Source.Should().BeNull(); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -133,14 +137,14 @@ public async Task Cannot_process_empty_operations_array() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: No operations found."); error.Detail.Should().BeNull(); error.Source.Should().BeNull(); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -163,15 +167,15 @@ public async Task Cannot_process_null_operation() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -204,7 +208,7 @@ public async Task Cannot_process_for_unknown_operation_code() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs index 925cd2e551..0d0a54bc58 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs @@ -21,7 +21,7 @@ public AtomicSerializationTests(IntegrationTestContext(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceDefinition(); @@ -31,16 +31,16 @@ public AtomicSerializationTests(IntegrationTestContext(); options.IncludeExceptionStackTraceInErrors = false; options.IncludeJsonApiVersion = true; - options.AllowClientGeneratedIds = true; + options.ClientIdGeneration = ClientIdGenerationMode.Allowed; } [Fact] public async Task Hides_data_for_void_operation() { // Arrange - Performer existingPerformer = _fakers.Performer.Generate(); + Performer existingPerformer = _fakers.Performer.GenerateOne(); - TextLanguage newLanguage = _fakers.TextLanguage.Generate(); + TextLanguage newLanguage = _fakers.TextLanguage.GenerateOne(); newLanguage.Id = Guid.NewGuid(); await _testContext.RunOnDatabaseAsync(async dbContext => @@ -89,46 +89,46 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Should().BeJson(@"{ - ""jsonapi"": { - ""version"": ""1.1"", - ""ext"": [ - ""https://jsonapi.org/ext/atomic"" - ] - }, - ""links"": { - ""self"": ""http://localhost/operations"" - }, - ""atomic:results"": [ - { - ""data"": null - }, - { - ""data"": { - ""type"": ""textLanguages"", - ""id"": """ + newLanguage.StringId + @""", - ""attributes"": { - ""isoCode"": """ + newLanguage.IsoCode + @" (changed)"" - }, - ""relationships"": { - ""lyrics"": { - ""links"": { - ""self"": ""http://localhost/textLanguages/" + newLanguage.StringId + @"/relationships/lyrics"", - ""related"": ""http://localhost/textLanguages/" + newLanguage.StringId + @"/lyrics"" + responseDocument.Should().BeJson($$""" + { + "jsonapi": { + "version": "1.1", + "ext": [ + "https://jsonapi.org/ext/atomic" + ] + }, + "links": { + "self": "http://localhost/operations" + }, + "atomic:results": [ + {}, + { + "data": { + "type": "textLanguages", + "id": "{{newLanguage.StringId}}", + "attributes": { + "isoCode": "{{newLanguage.IsoCode}} (changed)" + }, + "relationships": { + "lyrics": { + "links": { + "self": "http://localhost/textLanguages/{{newLanguage.StringId}}/relationships/lyrics", + "related": "http://localhost/textLanguages/{{newLanguage.StringId}}/lyrics" + } + } + }, + "links": { + "self": "http://localhost/textLanguages/{{newLanguage.StringId}}" + } + } + } + ] } - } - }, - ""links"": { - ""self"": ""http://localhost/textLanguages/" + newLanguage.StringId + @""" - } - } - } - ] -}"); + """); } [Fact] - public async Task Includes_version_with_ext_on_error_in_operations_endpoint() + public async Task Includes_version_with_ext_on_error_at_operations_endpoint() { // Arrange string musicTrackId = Unknown.StringId.For(); @@ -159,27 +159,29 @@ public async Task Includes_version_with_ext_on_error_in_operations_endpoint() string errorId = JsonApiStringConverter.ExtractErrorId(responseDocument); - responseDocument.Should().BeJson(@"{ - ""jsonapi"": { - ""version"": ""1.1"", - ""ext"": [ - ""https://jsonapi.org/ext/atomic"" - ] - }, - ""links"": { - ""self"": ""http://localhost/operations"" - }, - ""errors"": [ - { - ""id"": """ + errorId + @""", - ""status"": ""404"", - ""title"": ""The requested resource does not exist."", - ""detail"": ""Resource of type 'musicTracks' with ID '" + musicTrackId + @"' does not exist."", - ""source"": { - ""pointer"": ""/atomic:operations[0]"" - } - } - ] -}"); + responseDocument.Should().BeJson($$""" + { + "jsonapi": { + "version": "1.1", + "ext": [ + "https://jsonapi.org/ext/atomic" + ] + }, + "links": { + "self": "http://localhost/operations" + }, + "errors": [ + { + "id": "{{errorId}}", + "status": "404", + "title": "The requested resource does not exist.", + "detail": "Resource of type 'musicTracks' with ID '{{musicTrackId}}' does not exist.", + "source": { + "pointer": "/atomic:operations[0]" + } + } + ] + } + """); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs new file mode 100644 index 0000000000..0ea01e7d33 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/AtomicTraceLoggingTests.cs @@ -0,0 +1,333 @@ +using System.Net; +using System.Text.Json; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Mixed; + +public sealed class AtomicTraceLoggingTests : IClassFixture, OperationsDbContext>> +{ + private readonly IntegrationTestContext, OperationsDbContext> _testContext; + private readonly OperationsFakers _fakers = new(); + + public AtomicTraceLoggingTests(IntegrationTestContext, OperationsDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + + testContext.ConfigureLogging(options => + { + var loggerProvider = new CapturingLoggerProvider((category, level) => + level == LogLevel.Trace && category.StartsWith("JsonApiDotNetCore.", StringComparison.Ordinal)); + + options.AddProvider(loggerProvider); + options.SetMinimumLevel(LogLevel.Trace); + + options.Services.AddSingleton(loggerProvider); + }); + } + + [Fact] + public async Task Logs_execution_flow_at_Trace_level_on_operations_request() + { + // Arrange + var loggerProvider = _testContext.Factory.Services.GetRequiredService(); + loggerProvider.Clear(); + + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + existingTrack.Lyric = _fakers.Lyric.GenerateOne(); + existingTrack.OwnedBy = _fakers.RecordCompany.GenerateOne(); + existingTrack.Performers = _fakers.Performer.GenerateList(1); + + string newGenre = _fakers.MusicTrack.GenerateOne().Genre!; + + Lyric existingLyric = _fakers.Lyric.GenerateOne(); + RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); + Performer existingPerformer = _fakers.Performer.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.AddInRange(existingTrack, existingLyric, existingCompany, existingPerformer); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + attributes = new + { + genre = newGenre + }, + relationships = new + { + lyric = new + { + data = new + { + type = "lyrics", + id = existingLyric.StringId + } + }, + ownedBy = new + { + data = new + { + type = "recordCompanies", + id = existingCompany.StringId + } + }, + performers = new + { + data = new[] + { + new + { + type = "performers", + id = existingPerformer.StringId + } + } + } + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + IReadOnlyList logLines = loggerProvider.GetLines(); + + logLines.Should().BeEquivalentTo(new[] + { + $$""" + [TRACE] Received POST request at 'http://localhost/operations' with body: <<{ + "atomic:operations": [ + { + "op": "update", + "data": { + "type": "musicTracks", + "id": "{{existingTrack.StringId}}", + "attributes": { + "genre": "{{newGenre}}" + }, + "relationships": { + "lyric": { + "data": { + "type": "lyrics", + "id": "{{existingLyric.StringId}}" + } + }, + "ownedBy": { + "data": { + "type": "recordCompanies", + "id": "{{existingCompany.StringId}}" + } + }, + "performers": { + "data": [ + { + "type": "performers", + "id": "{{existingPerformer.StringId}}" + } + ] + } + } + } + } + ] + }>> + """, + $$""" + [TRACE] Entering PostOperationsAsync(operations: [ + { + "Resource": { + "Id": "{{existingTrack.StringId}}", + "Genre": "{{newGenre}}", + "ReleasedAt": "0001-01-01T00:00:00+00:00", + "Lyric": { + "CreatedAt": "0001-01-01T00:00:00+00:00", + "Id": {{existingLyric.Id}}, + "StringId": "{{existingLyric.StringId}}" + }, + "OwnedBy": { + "Tracks": [], + "Id": {{existingCompany.Id}}, + "StringId": "{{existingCompany.StringId}}" + }, + "Performers": [ + { + "BornAt": "0001-01-01T00:00:00+00:00", + "Id": {{existingPerformer.Id}}, + "StringId": "{{existingPerformer.StringId}}" + } + ], + "OccursIn": [], + "StringId": "{{existingTrack.StringId}}" + }, + "TargetedFields": { + "Attributes": [ + "genre" + ], + "Relationships": [ + "lyric", + "ownedBy", + "performers" + ] + }, + "Request": { + "Kind": "AtomicOperations", + "PrimaryId": "{{existingTrack.StringId}}", + "PrimaryResourceType": "musicTracks", + "IsCollection": false, + "IsReadOnly": false, + "WriteOperation": "UpdateResource", + "Extensions": [] + } + } + ]) + """, + $$""" + [TRACE] Entering UpdateAsync(id: {{existingTrack.StringId}}, resource: { + "Id": "{{existingTrack.StringId}}", + "Genre": "{{newGenre}}", + "ReleasedAt": "0001-01-01T00:00:00+00:00", + "Lyric": { + "CreatedAt": "0001-01-01T00:00:00+00:00", + "Id": {{existingLyric.Id}}, + "StringId": "{{existingLyric.StringId}}" + }, + "OwnedBy": { + "Tracks": [], + "Id": {{existingCompany.Id}}, + "StringId": "{{existingCompany.StringId}}" + }, + "Performers": [ + { + "BornAt": "0001-01-01T00:00:00+00:00", + "Id": {{existingPerformer.Id}}, + "StringId": "{{existingPerformer.StringId}}" + } + ], + "OccursIn": [], + "StringId": "{{existingTrack.StringId}}" + }) + """, + $$""" + [TRACE] Entering GetForUpdateAsync(queryLayer: QueryLayer + { + Include: lyric,ownedBy,performers + Filter: equals(id,'{{existingTrack.StringId}}') + } + ) + """, + $$""" + [TRACE] Entering GetAsync(queryLayer: QueryLayer + { + Include: lyric,ownedBy,performers + Filter: equals(id,'{{existingTrack.StringId}}') + } + ) + """, + $$""" + [TRACE] Entering ApplyQueryLayer(queryLayer: QueryLayer + { + Include: lyric,ownedBy,performers + Filter: equals(id,'{{existingTrack.StringId}}') + } + ) + """, + $$""" + [TRACE] Entering UpdateAsync(resourceFromRequest: { + "Id": "{{existingTrack.StringId}}", + "Genre": "{{newGenre}}", + "ReleasedAt": "0001-01-01T00:00:00+00:00", + "Lyric": { + "CreatedAt": "0001-01-01T00:00:00+00:00", + "Id": {{existingLyric.Id}}, + "StringId": "{{existingLyric.StringId}}" + }, + "OwnedBy": { + "Tracks": [], + "Id": {{existingCompany.Id}}, + "StringId": "{{existingCompany.StringId}}" + }, + "Performers": [ + { + "BornAt": "0001-01-01T00:00:00+00:00", + "Id": {{existingPerformer.Id}}, + "StringId": "{{existingPerformer.StringId}}" + } + ], + "OccursIn": [], + "StringId": "{{existingTrack.StringId}}" + }, resourceFromDatabase: { + "Id": "{{existingTrack.StringId}}", + "Title": "{{existingTrack.Title}}", + "LengthInSeconds": {{JsonSerializer.Serialize(existingTrack.LengthInSeconds)}}, + "Genre": "{{existingTrack.Genre}}", + "ReleasedAt": {{JsonSerializer.Serialize(existingTrack.ReleasedAt)}}, + "Lyric": { + "Format": "{{existingTrack.Lyric.Format}}", + "Text": {{JsonSerializer.Serialize(existingTrack.Lyric.Text)}}, + "CreatedAt": "0001-01-01T00:00:00+00:00", + "Id": {{existingTrack.Lyric.Id}}, + "StringId": "{{existingTrack.Lyric.StringId}}" + }, + "OwnedBy": { + "Name": "{{existingTrack.OwnedBy.Name}}", + "CountryOfResidence": "{{existingTrack.OwnedBy.CountryOfResidence}}", + "Tracks": [ + null + ], + "Id": {{existingTrack.OwnedBy.Id}}, + "StringId": "{{existingTrack.OwnedBy.StringId}}" + }, + "Performers": [ + { + "ArtistName": "{{existingTrack.Performers[0].ArtistName}}", + "BornAt": {{JsonSerializer.Serialize(existingTrack.Performers[0].BornAt)}}, + "Id": {{existingTrack.Performers[0].Id}}, + "StringId": "{{existingTrack.Performers[0].StringId}}" + } + ], + "OccursIn": [], + "StringId": "{{existingTrack.StringId}}" + }) + """, + $$""" + [TRACE] Entering GetAsync(queryLayer: QueryLayer + { + Filter: equals(id,'{{existingTrack.StringId}}') + } + ) + """, + $$""" + [TRACE] Entering ApplyQueryLayer(queryLayer: QueryLayer + { + Filter: equals(id,'{{existingTrack.StringId}}') + } + ) + """ + }, options => options.Using(IgnoreLineEndingsComparer.Instance).WithStrictOrdering()); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs index f24e25a216..b18c4991db 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs @@ -65,15 +65,15 @@ public async Task Cannot_process_more_operations_than_maximum() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Too many operations in request."); error.Detail.Should().Be("The number of operations in this request (3) is higher than the maximum of 2."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs index 0eadc45f2c..18e7640d22 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs @@ -2,6 +2,7 @@ using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; @@ -50,29 +51,75 @@ public async Task Cannot_create_resource_with_multiple_violations() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(2); + responseDocument.Errors.Should().HaveCount(2); ErrorObject error1 = responseDocument.Errors[0]; error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error1.Title.Should().Be("Input validation failed."); error1.Detail.Should().Be("The Title field is required."); - error1.Source.ShouldNotBeNull(); + error1.Source.Should().NotBeNull(); error1.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/title"); ErrorObject error2 = responseDocument.Errors[1]; error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error2.Title.Should().Be("Input validation failed."); error2.Detail.Should().Be("The field LengthInSeconds must be between 1 and 1440."); - error2.Source.ShouldNotBeNull(); + error2.Source.Should().NotBeNull(); error2.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/lengthInSeconds"); } + [Fact] + public async Task Cannot_create_resource_when_violation_from_custom_ValidationAttribute() + { + // Arrange + var timeProvider = _testContext.Factory.Services.GetRequiredService(); + DateTimeOffset utcNow = timeProvider.GetUtcNow(); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + attributes = new + { + title = "some", + lengthInSeconds = 120, + releasedAt = utcNow.AddDays(1) + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Input validation failed."); + error.Detail.Should().Be("ReleasedAt must be in the past."); + error.Source.Should().NotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/releasedAt"); + } + [Fact] public async Task Can_create_resource_with_annotated_relationship() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - string newPlaylistName = _fakers.Playlist.Generate().Name; + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + string newPlaylistName = _fakers.Playlist.GenerateOne().Name; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -121,15 +168,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.Should().HaveCount(1); - long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().Id.ShouldNotBeNull()); + long newPlaylistId = long.Parse(responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.Id.Should().NotBeNull().And.Subject); await _testContext.RunOnDatabaseAsync(async dbContext => { Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(newPlaylistId); - playlistInDatabase.Tracks.ShouldHaveCount(1); + playlistInDatabase.Tracks.Should().HaveCount(1); playlistInDatabase.Tracks[0].Id.Should().Be(existingTrack.Id); }); } @@ -138,7 +185,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_update_resource_with_multiple_violations() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -175,20 +222,20 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(2); + responseDocument.Errors.Should().HaveCount(2); ErrorObject error1 = responseDocument.Errors[0]; error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error1.Title.Should().Be("Input validation failed."); error1.Detail.Should().Be("The Title field is required."); - error1.Source.ShouldNotBeNull(); + error1.Source.Should().NotBeNull(); error1.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/title"); ErrorObject error2 = responseDocument.Errors[1]; error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error2.Title.Should().Be("Input validation failed."); error2.Detail.Should().Be("The field LengthInSeconds must be between 1 and 1440."); - error2.Source.ShouldNotBeNull(); + error2.Source.Should().NotBeNull(); error2.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/lengthInSeconds"); } @@ -196,8 +243,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_update_resource_with_omitted_required_attribute() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - string newTrackGenre = _fakers.MusicTrack.Generate().Genre!; + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + string newTrackGenre = _fakers.MusicTrack.GenerateOne().Genre!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -248,8 +295,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_update_resource_with_annotated_relationship() { // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + Playlist existingPlaylist = _fakers.Playlist.GenerateOne(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -301,7 +348,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - playlistInDatabase.Tracks.ShouldHaveCount(1); + playlistInDatabase.Tracks.Should().HaveCount(1); playlistInDatabase.Tracks[0].Id.Should().Be(existingTrack.Id); }); } @@ -310,8 +357,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_update_ManyToOne_relationship() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -355,7 +402,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.OwnedBy.ShouldNotBeNull(); + trackInDatabase.OwnedBy.Should().NotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); }); } @@ -364,8 +411,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_update_ManyToMany_relationship() { // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + Playlist existingPlaylist = _fakers.Playlist.GenerateOne(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -412,7 +459,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - playlistInDatabase.Tracks.ShouldHaveCount(1); + playlistInDatabase.Tracks.Should().HaveCount(1); playlistInDatabase.Tracks[0].Id.Should().Be(existingTrack.Id); }); } @@ -462,20 +509,20 @@ public async Task Validates_all_operations_before_execution_starts() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(2); + responseDocument.Errors.Should().HaveCount(2); ErrorObject error1 = responseDocument.Errors[0]; error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error1.Title.Should().Be("Input validation failed."); error1.Detail.Should().Be("The Name field is required."); - error1.Source.ShouldNotBeNull(); + error1.Source.Should().NotBeNull(); error1.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/name"); ErrorObject error2 = responseDocument.Errors[1]; error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error2.Title.Should().Be("Input validation failed."); error2.Detail.Should().Be("The field LengthInSeconds must be between 1 and 1440."); - error2.Source.ShouldNotBeNull(); + error2.Source.Should().NotBeNull(); error2.Source.Pointer.Should().Be("/atomic:operations[1]/data/attributes/lengthInSeconds"); } @@ -546,7 +593,7 @@ public async Task Does_not_exceed_MaxModelValidationErrors() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(3); + responseDocument.Errors.Should().HaveCount(3); ErrorObject error1 = responseDocument.Errors[0]; error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); @@ -558,14 +605,14 @@ public async Task Does_not_exceed_MaxModelValidationErrors() error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error2.Title.Should().Be("Input validation failed."); error2.Detail.Should().Be("The Name field is required."); - error2.Source.ShouldNotBeNull(); + error2.Source.Should().NotBeNull(); error2.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/name"); ErrorObject error3 = responseDocument.Errors[2]; error3.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error3.Title.Should().Be("Input validation failed."); error3.Detail.Should().Be("The Name field is required."); - error3.Source.ShouldNotBeNull(); + error3.Source.Should().NotBeNull(); error3.Source.Pointer.Should().Be("/atomic:operations[1]/data/attributes/name"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs index 42bbbdfd3b..0abf7385ee 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/MusicTrack.cs @@ -9,7 +9,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations")] public sealed class MusicTrack : Identifiable { - [RegularExpression(@"(?im)^[{(]?[0-9A-F]{8}[-]?(?:[0-9A-F]{4}[-]?){3}[0-9A-F]{12}[)}]?$")] + [RegularExpression("(?im)^[{(]?[0-9A-F]{8}[-]?(?:[0-9A-F]{4}[-]?){3}[0-9A-F]{12}[)}]?$")] public override Guid Id { get; set; } [Attr] @@ -23,6 +23,7 @@ public sealed class MusicTrack : Identifiable public string? Genre { get; set; } [Attr] + [DateMustBeInThePast] public DateTimeOffset ReleasedAt { get; set; } [HasOne] @@ -34,6 +35,6 @@ public sealed class MusicTrack : Identifiable [HasMany] public IList Performers { get; set; } = new List(); - [HasMany] + [HasMany(Capabilities = HasManyCapabilities.All & ~(HasManyCapabilities.AllowSet | HasManyCapabilities.AllowAdd | HasManyCapabilities.AllowRemove))] public IList OccursIn { get; set; } = new List(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsController.cs index 08ef7b169b..2ea1b88bad 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsController.cs @@ -7,11 +7,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; -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 sealed class OperationsController( + IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request, + ITargetedFields targetedFields, IAtomicOperationFilter operationFilter) + : JsonApiOperationsController(options, resourceGraph, loggerFactory, processor, request, targetedFields, operationFilter); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs index 45be92ce8b..e13a922941 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs @@ -1,12 +1,14 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; // @formatter:wrap_chained_method_calls chop_always namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class OperationsDbContext : DbContext +public sealed class OperationsDbContext(DbContextOptions options) + : TestableDbContext(options) { public DbSet Playlists => Set(); public DbSet MusicTracks => Set(); @@ -15,11 +17,6 @@ public sealed class OperationsDbContext : DbContext public DbSet Performers => Set(); public DbSet RecordCompanies => Set(); - public OperationsDbContext(DbContextOptions options) - : base(options) - { - } - protected override void OnModelCreating(ModelBuilder builder) { builder.Entity() @@ -30,5 +27,7 @@ protected override void OnModelCreating(ModelBuilder builder) builder.Entity() .HasMany(musicTrack => musicTrack.OccursIn) .WithMany(playlist => playlist.Tracks); + + base.OnModelCreating(builder); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/OperationsFakers.cs index 8cf3c07e41..f09dfbdf0a 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 +internal sealed class OperationsFakers { - 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() + .MakeDeterministic() + .RuleFor(playlist => playlist.Name, faker => faker.Lorem.Sentence())); + + private readonly Lazy> _lazyMusicTrackFaker = new(() => new Faker() + .MakeDeterministic() + .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() + .MakeDeterministic() + .RuleFor(lyric => lyric.Text, faker => faker.Lorem.Text()) + .RuleFor(lyric => lyric.Format, "LRC")); + + private readonly Lazy> _lazyTextLanguageFaker = new(() => new Faker() + .MakeDeterministic() + .RuleFor(textLanguage => textLanguage.IsoCode, faker => faker.PickRandom(LazyLanguageIsoCodes.Value))); + + private readonly Lazy> _lazyPerformerFaker = new(() => new Faker() + .MakeDeterministic() + .RuleFor(performer => performer.ArtistName, faker => faker.Name.FullName()) + .RuleFor(performer => performer.BornAt, faker => faker.Date.PastOffset().TruncateToWholeMilliseconds())); + + private readonly Lazy> _lazyRecordCompanyFaker = new(() => new Faker() + .MakeDeterministic() + .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/Playlist.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Playlist.cs index e8baf731d0..0d6409e761 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Playlist.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Playlist.cs @@ -12,8 +12,8 @@ public sealed class Playlist : Identifiable [Attr] public string Name { get; set; } = null!; - [NotMapped] [Attr] + [NotMapped] public bool IsArchived => false; [HasMany] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs index 640e6ac3fe..a635e12fb6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs @@ -1,9 +1,7 @@ using System.Net; using FluentAssertions; -using FluentAssertions.Extensions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; @@ -12,8 +10,6 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.QueryStrings; public sealed class AtomicQueryStringTests : IClassFixture, OperationsDbContext>> { - private static readonly DateTime FrozenTime = 30.July(2018).At(13, 46, 12).AsUtc(); - private readonly IntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new(); @@ -24,19 +20,11 @@ public AtomicQueryStringTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddSingleton(new FrozenSystemClock - { - UtcNow = FrozenTime - }); - - services.AddResourceDefinition(); - }); + testContext.ConfigureServices(services => services.AddResourceDefinition()); } [Fact] - public async Task Cannot_include_on_operations_endpoint() + public async Task Cannot_include_at_operations_endpoint() { // Arrange var requestBody = new @@ -65,18 +53,18 @@ public async Task Cannot_include_on_operations_endpoint() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); error.Detail.Should().Be("The parameter 'include' cannot be used at this endpoint."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("include"); } [Fact] - public async Task Cannot_filter_on_operations_endpoint() + public async Task Cannot_filter_at_operations_endpoint() { // Arrange var requestBody = new @@ -105,18 +93,18 @@ public async Task Cannot_filter_on_operations_endpoint() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); error.Detail.Should().Be("The parameter 'filter' cannot be used at this endpoint."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("filter"); } [Fact] - public async Task Cannot_sort_on_operations_endpoint() + public async Task Cannot_sort_at_operations_endpoint() { // Arrange var requestBody = new @@ -145,18 +133,18 @@ public async Task Cannot_sort_on_operations_endpoint() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); error.Detail.Should().Be("The parameter 'sort' cannot be used at this endpoint."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("sort"); } [Fact] - public async Task Cannot_use_pagination_number_on_operations_endpoint() + public async Task Cannot_use_pagination_number_at_operations_endpoint() { // Arrange var requestBody = new @@ -185,18 +173,18 @@ public async Task Cannot_use_pagination_number_on_operations_endpoint() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); error.Detail.Should().Be("The parameter 'page[number]' cannot be used at this endpoint."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("page[number]"); } [Fact] - public async Task Cannot_use_pagination_size_on_operations_endpoint() + public async Task Cannot_use_pagination_size_at_operations_endpoint() { // Arrange var requestBody = new @@ -225,18 +213,18 @@ public async Task Cannot_use_pagination_size_on_operations_endpoint() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); error.Detail.Should().Be("The parameter 'page[size]' cannot be used at this endpoint."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("page[size]"); } [Fact] - public async Task Cannot_use_sparse_fieldset_on_operations_endpoint() + public async Task Cannot_use_sparse_fieldset_at_operations_endpoint() { // Arrange var requestBody = new @@ -265,24 +253,27 @@ public async Task Cannot_use_sparse_fieldset_on_operations_endpoint() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Usage of one or more query string parameters is not allowed at the requested endpoint."); error.Detail.Should().Be("The parameter 'fields[recordCompanies]' cannot be used at this endpoint."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("fields[recordCompanies]"); } [Fact] - public async Task Can_use_Queryable_handler_on_resource_endpoint() + public async Task Can_use_Queryable_handler_at_resource_endpoint() { // Arrange - List musicTracks = _fakers.MusicTrack.Generate(3); - musicTracks[0].ReleasedAt = FrozenTime.AddMonths(5); - musicTracks[1].ReleasedAt = FrozenTime.AddMonths(-5); - musicTracks[2].ReleasedAt = FrozenTime.AddMonths(-1); + var timeProvider = _testContext.Factory.Services.GetRequiredService(); + DateTimeOffset utcNow = timeProvider.GetUtcNow(); + + List musicTracks = _fakers.MusicTrack.GenerateList(3); + musicTracks[0].ReleasedAt = utcNow.AddMonths(5); + musicTracks[1].ReleasedAt = utcNow.AddMonths(-5); + musicTracks[2].ReleasedAt = utcNow.AddMonths(-1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -299,15 +290,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(musicTracks[2].StringId); } [Fact] - public async Task Cannot_use_Queryable_handler_on_operations_endpoint() + public async Task Cannot_use_Queryable_handler_at_operations_endpoint() { // Arrange - string newTrackTitle = _fakers.MusicTrack.Generate().Title; + string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; var requestBody = new { @@ -336,7 +327,7 @@ public async Task Cannot_use_Queryable_handler_on_operations_endpoint() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); @@ -345,7 +336,7 @@ public async Task Cannot_use_Queryable_handler_on_operations_endpoint() error.Detail.Should().Be("Query string parameter 'isRecentlyReleased' is unknown. " + "Set 'AllowUnknownQueryStringParameters' to 'true' in options to ignore unknown parameters."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("isRecentlyReleased"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/MusicTrackReleaseDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/MusicTrackReleaseDefinition.cs index fdf91e9744..b72b3eb3d2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/MusicTrackReleaseDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/QueryStrings/MusicTrackReleaseDefinition.cs @@ -1,8 +1,6 @@ using JetBrains.Annotations; -using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; -using Microsoft.AspNetCore.Authentication; using Microsoft.Extensions.Primitives; namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.QueryStrings; @@ -10,14 +8,14 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.QueryStrings; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public sealed class MusicTrackReleaseDefinition : JsonApiResourceDefinition { - private readonly ISystemClock _systemClock; + private readonly TimeProvider _timeProvider; - public MusicTrackReleaseDefinition(IResourceGraph resourceGraph, ISystemClock systemClock) + public MusicTrackReleaseDefinition(IResourceGraph resourceGraph, TimeProvider timeProvider) : base(resourceGraph) { - ArgumentGuard.NotNull(systemClock, nameof(systemClock)); + ArgumentNullException.ThrowIfNull(timeProvider); - _systemClock = systemClock; + _timeProvider = timeProvider; } public override QueryStringParameterHandlers OnRegisterQueryableHandlersForQueryStringParameters() @@ -32,9 +30,10 @@ private IQueryable FilterOnRecentlyReleased(IQueryable s { IQueryable tracks = source; - if (bool.Parse(parameterValue)) + if (bool.Parse(parameterValue.ToString())) { - tracks = tracks.Where(musicTrack => musicTrack.ReleasedAt < _systemClock.UtcNow && musicTrack.ReleasedAt > _systemClock.UtcNow.AddMonths(-3)); + DateTimeOffset utcNow = _timeProvider.GetUtcNow(); + tracks = tracks.Where(musicTrack => musicTrack.ReleasedAt < utcNow && musicTrack.ReleasedAt > utcNow.AddMonths(-3)); } return tracks; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs index 27e44ec234..cb0325882e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs @@ -22,7 +22,7 @@ public AtomicSerializationResourceDefinitionTests(IntegrationTestContext(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceDefinition(); @@ -40,7 +40,7 @@ public async Task Transforms_on_create_resource_with_side_effects() // Arrange var hitCounter = _testContext.Factory.Services.GetRequiredService(); - List newCompanies = _fakers.RecordCompany.Generate(2); + List newCompanies = _fakers.RecordCompany.GenerateList(2); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -88,28 +88,28 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(2); + responseDocument.Results.Should().HaveCount(2); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newCompanies[0].Name.ToUpperInvariant())); + resource.Attributes.Should().ContainKey("name").WhoseValue.Should().Be(newCompanies[0].Name.ToUpperInvariant()); string countryOfResidence = newCompanies[0].CountryOfResidence!.ToUpperInvariant(); - resource.Attributes.ShouldContainKey("countryOfResidence").With(value => value.Should().Be(countryOfResidence)); + resource.Attributes.Should().ContainKey("countryOfResidence").WhoseValue.Should().Be(countryOfResidence); }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[1].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newCompanies[1].Name.ToUpperInvariant())); + resource.Attributes.Should().ContainKey("name").WhoseValue.Should().Be(newCompanies[1].Name.ToUpperInvariant()); string countryOfResidence = newCompanies[1].CountryOfResidence!.ToUpperInvariant(); - resource.Attributes.ShouldContainKey("countryOfResidence").With(value => value.Should().Be(countryOfResidence)); + resource.Attributes.Should().ContainKey("countryOfResidence").WhoseValue.Should().Be(countryOfResidence); }); await _testContext.RunOnDatabaseAsync(async dbContext => { List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.ShouldHaveCount(2); + companiesInDatabase.Should().HaveCount(2); companiesInDatabase[0].Name.Should().Be(newCompanies[0].Name.ToUpperInvariant()); companiesInDatabase[0].CountryOfResidence.Should().Be(newCompanies[0].CountryOfResidence); @@ -133,9 +133,9 @@ public async Task Skips_on_create_resource_with_ToOne_relationship() // Arrange var hitCounter = _testContext.Factory.Services.GetRequiredService(); - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); - string newTrackTitle = _fakers.MusicTrack.Generate().Title; + string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -181,7 +181,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.Should().HaveCount(1); hitCounter.HitExtensibilityPoints.Should().BeEmpty(); } @@ -192,7 +192,7 @@ public async Task Transforms_on_update_resource_with_side_effects() // Arrange var hitCounter = _testContext.Factory.Services.GetRequiredService(); - List existingCompanies = _fakers.RecordCompany.Generate(2); + List existingCompanies = _fakers.RecordCompany.GenerateList(2); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -240,28 +240,28 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(2); + responseDocument.Results.Should().HaveCount(2); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(existingCompanies[0].Name)); + resource.Attributes.Should().ContainKey("name").WhoseValue.Should().Be(existingCompanies[0].Name); string countryOfResidence = existingCompanies[0].CountryOfResidence!.ToUpperInvariant(); - resource.Attributes.ShouldContainKey("countryOfResidence").With(value => value.Should().Be(countryOfResidence)); + resource.Attributes.Should().ContainKey("countryOfResidence").WhoseValue.Should().Be(countryOfResidence); }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[1].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { - resource.Attributes.ShouldContainKey("name").With(value => value.Should().Be(existingCompanies[1].Name)); + resource.Attributes.Should().ContainKey("name").WhoseValue.Should().Be(existingCompanies[1].Name); string countryOfResidence = existingCompanies[1].CountryOfResidence!.ToUpperInvariant(); - resource.Attributes.ShouldContainKey("countryOfResidence").With(value => value.Should().Be(countryOfResidence)); + resource.Attributes.Should().ContainKey("countryOfResidence").WhoseValue.Should().Be(countryOfResidence); }); await _testContext.RunOnDatabaseAsync(async dbContext => { List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.ShouldHaveCount(2); + companiesInDatabase.Should().HaveCount(2); companiesInDatabase[0].Name.Should().Be(existingCompanies[0].Name); companiesInDatabase[0].CountryOfResidence.Should().Be(existingCompanies[0].CountryOfResidence); @@ -285,8 +285,8 @@ public async Task Skips_on_update_resource_with_ToOne_relationship() // Arrange var hitCounter = _testContext.Factory.Services.GetRequiredService(); - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -332,7 +332,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.Should().HaveCount(1); hitCounter.HitExtensibilityPoints.Should().BeEmpty(); } @@ -343,8 +343,8 @@ public async Task Skips_on_update_ToOne_relationship() // Arrange var hitCounter = _testContext.Factory.Services.GetRequiredService(); - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/RecordCompanyDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/RecordCompanyDefinition.cs index 4db0e6fe3c..4c89170efc 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/RecordCompanyDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/RecordCompanyDefinition.cs @@ -4,15 +4,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.ResourceDefinitions.Serialization; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class RecordCompanyDefinition : HitCountingResourceDefinition +public sealed class RecordCompanyDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter) + : HitCountingResourceDefinition(resourceGraph, hitCounter) { protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.Serialization; - public RecordCompanyDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph, hitCounter) - { - } - public override void OnDeserialize(RecordCompany resource) { base.OnDeserialize(resource); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs index f70a289ba1..9263d1ec32 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs @@ -21,7 +21,7 @@ public AtomicSparseFieldSetResourceDefinitionTests(IntegrationTestContext(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceDefinition(); @@ -43,7 +43,7 @@ public async Task Hides_text_in_create_resource_with_side_effects() var provider = _testContext.Factory.Services.GetRequiredService(); provider.CanViewText = false; - List newLyrics = _fakers.Lyric.Generate(2); + List newLyrics = _fakers.Lyric.GenerateList(2); var requestBody = new { @@ -86,17 +86,17 @@ public async Task Hides_text_in_create_resource_with_side_effects() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(2); + responseDocument.Results.Should().HaveCount(2); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { - resource.Attributes.ShouldContainKey("format").With(value => value.Should().Be(newLyrics[0].Format)); + resource.Attributes.Should().ContainKey("format").WhoseValue.Should().Be(newLyrics[0].Format); resource.Attributes.Should().NotContainKey("text"); }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[1].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { - resource.Attributes.ShouldContainKey("format").With(value => value.Should().Be(newLyrics[1].Format)); + resource.Attributes.Should().ContainKey("format").WhoseValue.Should().Be(newLyrics[1].Format); resource.Attributes.Should().NotContainKey("text"); }); @@ -118,7 +118,7 @@ public async Task Hides_text_in_update_resource_with_side_effects() var provider = _testContext.Factory.Services.GetRequiredService(); provider.CanViewText = false; - List existingLyrics = _fakers.Lyric.Generate(2); + List existingLyrics = _fakers.Lyric.GenerateList(2); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -165,17 +165,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(2); + responseDocument.Results.Should().HaveCount(2); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { - resource.Attributes.ShouldContainKey("format").With(value => value.Should().Be(existingLyrics[0].Format)); + resource.Attributes.Should().ContainKey("format").WhoseValue.Should().Be(existingLyrics[0].Format); resource.Attributes.Should().NotContainKey("text"); }); - responseDocument.Results[1].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[1].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { - resource.Attributes.ShouldContainKey("format").With(value => value.Should().Be(existingLyrics[1].Format)); + resource.Attributes.Should().ContainKey("format").WhoseValue.Should().Be(existingLyrics[1].Format); resource.Attributes.Should().NotContainKey("text"); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricTextDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricTextDefinition.cs index 3abc4a134c..8f38b1aa32 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricTextDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricTextDefinition.cs @@ -5,18 +5,13 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.ResourceDefinitions.SparseFieldSets; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class LyricTextDefinition : HitCountingResourceDefinition +public sealed class LyricTextDefinition(IResourceGraph resourceGraph, LyricPermissionProvider lyricPermissionProvider, ResourceDefinitionHitCounter hitCounter) + : HitCountingResourceDefinition(resourceGraph, hitCounter) { - private readonly LyricPermissionProvider _lyricPermissionProvider; + private readonly LyricPermissionProvider _lyricPermissionProvider = lyricPermissionProvider; protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.OnApplySparseFieldSet; - public LyricTextDefinition(IResourceGraph resourceGraph, LyricPermissionProvider lyricPermissionProvider, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph, hitCounter) - { - _lyricPermissionProvider = lyricPermissionProvider; - } - public override SparseFieldSetExpression? OnApplySparseFieldSet(SparseFieldSetExpression? existingSparseFieldSet) { base.OnApplySparseFieldSet(existingSparseFieldSet); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/TextLanguage.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/TextLanguage.cs index 02e8bf6278..e4e440600d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/TextLanguage.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/TextLanguage.cs @@ -1,11 +1,13 @@ using JetBrains.Annotations; +using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations")] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations", + GenerateControllerEndpoints = JsonApiEndpoints.Post | JsonApiEndpoints.Patch)] public sealed class TextLanguage : Identifiable { [Attr] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs index 82646686d4..f27e6c4a93 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs @@ -23,9 +23,9 @@ public AtomicRollbackTests(IntegrationTestContext { @@ -88,13 +88,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); error.Title.Should().Be("A related resource does not exist."); error.Detail.Should().Be($"Related resource of type 'performers' with ID '{unknownPerformerId}' in relationship 'performers' does not exist."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[1]"); await _testContext.RunOnDatabaseAsync(async dbContext => @@ -111,7 +111,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_restore_to_previous_savepoint_on_error() { // Arrange - string newTrackTitle = _fakers.MusicTrack.Generate().Title; + string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -168,13 +168,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); error.Title.Should().Be("A related resource does not exist."); error.Detail.Should().Be($"Related resource of type 'performers' with ID '{unknownPerformerId}' in relationship 'performers' does not exist."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[1]"); await _testContext.RunOnDatabaseAsync(async dbContext => diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs index 97f0e08ff5..14cfc466a0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs @@ -9,7 +9,8 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Transactions; -public sealed class AtomicTransactionConsistencyTests : IClassFixture, OperationsDbContext>> +public sealed class AtomicTransactionConsistencyTests + : IClassFixture, OperationsDbContext>>, IAsyncLifetime { private readonly IntegrationTestContext, OperationsDbContext> _testContext; private readonly OperationsFakers _fakers = new(); @@ -20,14 +21,14 @@ public AtomicTransactionConsistencyTests(IntegrationTestContext(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceRepository(); services.AddResourceRepository(); services.AddResourceRepository(); - string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - string dbConnectionString = $"Host=localhost;Port=5432;Database=JsonApiTest-{Guid.NewGuid():N};User ID=postgres;Password={postgresPassword}"; + string dbConnectionString = + $"Host=localhost;Database=JsonApiTest-Extra-{Guid.NewGuid():N};User ID=postgres;Password=postgres;Include Error Detail=true"; services.AddDbContext(options => options.UseNpgsql(dbConnectionString)); }); @@ -63,13 +64,13 @@ public async Task Cannot_use_non_transactional_repository() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Unsupported resource type in atomic:operations request."); error.Detail.Should().Be("Operations on resources of type 'performers' cannot be used because transaction support is unavailable."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); } @@ -77,7 +78,7 @@ public async Task Cannot_use_non_transactional_repository() public async Task Cannot_use_transactional_repository_without_active_transaction() { // Arrange - string newTrackTitle = _fakers.MusicTrack.Generate().Title; + string newTrackTitle = _fakers.MusicTrack.GenerateOne().Title; var requestBody = new { @@ -106,13 +107,13 @@ public async Task Cannot_use_transactional_repository_without_active_transaction // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Unsupported combination of resource types in atomic:operations request."); error.Detail.Should().Be("All operations need to participate in a single shared transaction, which is not the case for this request."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); } @@ -120,7 +121,7 @@ public async Task Cannot_use_transactional_repository_without_active_transaction public async Task Cannot_use_distributed_transaction() { // Arrange - string newLyricText = _fakers.Lyric.Generate().Text; + string newLyricText = _fakers.Lyric.GenerateOne().Text; var requestBody = new { @@ -149,13 +150,31 @@ public async Task Cannot_use_distributed_transaction() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Unsupported combination of resource types in atomic:operations request."); error.Detail.Should().Be("All operations need to participate in a single shared transaction, which is not the case for this request."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); } + + public Task InitializeAsync() + { + return Task.CompletedTask; + } + + Task IAsyncLifetime.DisposeAsync() + { + return DeleteExtraDatabaseAsync(); + } + + private async Task DeleteExtraDatabaseAsync() + { + await using AsyncServiceScope scope = _testContext.Factory.Services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + await dbContext.Database.EnsureDeletedAsync(); + } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/ExtraDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/ExtraDbContext.cs index 85fe191e86..290efd3437 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/ExtraDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/ExtraDbContext.cs @@ -1,13 +1,9 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Transactions; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class ExtraDbContext : DbContext -{ - public ExtraDbContext(DbContextOptions options) - : base(options) - { - } -} +public sealed class ExtraDbContext(DbContextOptions options) + : TestableDbContext(options); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/MusicTrackRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/MusicTrackRepository.cs index 6512b3fb27..7766b67570 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/MusicTrackRepository.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Transactions/MusicTrackRepository.cs @@ -8,14 +8,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Transactions; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class MusicTrackRepository : EntityFrameworkCoreRepository +public sealed class MusicTrackRepository( + ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, + IEnumerable constraintProviders, ILoggerFactory loggerFactory, IResourceDefinitionAccessor resourceDefinitionAccessor) + : EntityFrameworkCoreRepository(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, + resourceDefinitionAccessor) { public override string? TransactionId => null; - - public MusicTrackRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, - IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) - { - } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs index fc13524e8a..419c8e5b16 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs @@ -23,8 +23,8 @@ public AtomicAddToToManyRelationshipTests(IntegrationTestContext { @@ -62,25 +62,25 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Forbidden); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Forbidden); error.Title.Should().Be("Failed to deserialize request body: Only to-many relationships can be targeted through this operation."); error.Detail.Should().Be("Relationship 'ownedBy' is not a to-many relationship."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Can_add_to_OneToMany_relationship() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Performers = _fakers.Performer.Generate(1); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + existingTrack.Performers = _fakers.Performer.GenerateList(1); - List existingPerformers = _fakers.Performer.Generate(2); + List existingPerformers = _fakers.Performer.GenerateList(2); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -146,7 +146,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Performers.ShouldHaveCount(3); + trackInDatabase.Performers.Should().HaveCount(3); trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingTrack.Performers[0].Id); trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[0].Id); trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[1].Id); @@ -157,10 +157,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_add_to_ManyToMany_relationship() { // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); - existingPlaylist.Tracks = _fakers.MusicTrack.Generate(1); + Playlist existingPlaylist = _fakers.Playlist.GenerateOne(); + existingPlaylist.Tracks = _fakers.MusicTrack.GenerateList(1); - List existingTracks = _fakers.MusicTrack.Generate(2); + List existingTracks = _fakers.MusicTrack.GenerateList(2); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -226,7 +226,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - playlistInDatabase.Tracks.ShouldHaveCount(3); + playlistInDatabase.Tracks.Should().HaveCount(3); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingPlaylist.Tracks[0].Id); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); @@ -244,7 +244,7 @@ public async Task Cannot_add_for_href_element() new { op = "add", - href = "/api/v1/musicTracks/1/relationships/performers" + href = "/api/musicTracks/1/relationships/performers" } } }; @@ -257,15 +257,15 @@ public async Task Cannot_add_for_href_element() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -296,15 +296,15 @@ public async Task Cannot_add_for_missing_type_in_ref() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -336,15 +336,15 @@ public async Task Cannot_add_for_unknown_type_in_ref() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -375,22 +375,22 @@ public async Task Cannot_add_for_missing_ID_in_ref() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(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.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_add_for_unknown_ID_in_ref() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -433,13 +433,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); error.Title.Should().Be("The requested resource does not exist."); error.Detail.Should().Be($"Resource of type 'recordCompanies' with ID '{companyId}' does not exist."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); error.Meta.Should().NotContainKey("requestBody"); } @@ -474,15 +474,15 @@ public async Task Cannot_add_for_ID_and_local_ID_in_ref() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -513,15 +513,15 @@ public async Task Cannot_add_for_missing_relationship_in_ref() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'relationship' element is required."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -553,22 +553,22 @@ public async Task Cannot_add_for_unknown_relationship_in_ref() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Unknown relationship found."); error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'performers'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_add_for_missing_data() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -601,22 +601,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_add_for_null_data() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -650,22 +650,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of 'null'."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_add_for_object_data() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -701,15 +701,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of an object."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -748,15 +748,15 @@ public async Task Cannot_add_for_missing_type_in_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -796,15 +796,15 @@ public async Task Cannot_add_for_unknown_type_in_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -843,15 +843,15 @@ public async Task Cannot_add_for_missing_ID_in_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(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.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -892,28 +892,28 @@ public async Task Cannot_add_for_ID_and_local_ID_in_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_add_for_unknown_IDs_in_data() { // Arrange - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); string[] trackIds = - { + [ Unknown.StringId.For(), Unknown.StringId.AltFor() - }; + ]; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -959,20 +959,20 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(2); + responseDocument.Errors.Should().HaveCount(2); ErrorObject error1 = responseDocument.Errors[0]; error1.StatusCode.Should().Be(HttpStatusCode.NotFound); error1.Title.Should().Be("A related resource does not exist."); error1.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[0]}' in relationship 'tracks' does not exist."); - error1.Source.ShouldNotBeNull(); + error1.Source.Should().NotBeNull(); error1.Source.Pointer.Should().Be("/atomic:operations[0]"); ErrorObject error2 = responseDocument.Errors[1]; error2.StatusCode.Should().Be(HttpStatusCode.NotFound); error2.Title.Should().Be("A related resource does not exist."); error2.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[1]}' in relationship 'tracks' does not exist."); - error2.Source.ShouldNotBeNull(); + error2.Source.Should().NotBeNull(); error2.Source.Pointer.Should().Be("/atomic:operations[0]"); } @@ -980,7 +980,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_add_for_relationship_mismatch_between_ref_and_data() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1021,23 +1021,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Conflict); error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); error.Detail.Should().Be("Type 'playlists' is not convertible to type 'performers' of relationship 'performers'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Can_add_with_empty_data_array() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Performers = _fakers.Performer.Generate(1); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + existingTrack.Performers = _fakers.Performer.GenerateList(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1077,8 +1077,61 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Performers.ShouldHaveCount(1); + trackInDatabase.Performers.Should().HaveCount(1); trackInDatabase.Performers[0].Id.Should().Be(existingTrack.Performers[0].Id); }); } + + [Fact] + public async Task Cannot_add_with_blocked_capability() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "occursIn" + }, + data = new + { + type = "playlists", + id = Unknown.StringId.For() + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Relationship cannot be added to."); + error.Detail.Should().Be("The relationship 'occursIn' on resource type 'musicTracks' cannot be added to."); + error.Source.Should().NotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); + error.Meta.Should().HaveRequestBody(); + } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs index d015cae3fd..98d954c910 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs @@ -23,8 +23,8 @@ public AtomicRemoveFromToManyRelationshipTests(IntegrationTestContext { @@ -62,23 +62,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Forbidden); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Forbidden); error.Title.Should().Be("Failed to deserialize request body: Only to-many relationships can be targeted through this operation."); error.Detail.Should().Be("Relationship 'ownedBy' is not a to-many relationship."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Can_remove_from_OneToMany_relationship() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Performers = _fakers.Performer.Generate(3); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + existingTrack.Performers = _fakers.Performer.GenerateList(3); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -144,11 +144,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Performers.ShouldHaveCount(1); + trackInDatabase.Performers.Should().HaveCount(1); trackInDatabase.Performers[0].Id.Should().Be(existingTrack.Performers[1].Id); List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.ShouldHaveCount(3); + performersInDatabase.Should().HaveCount(3); }); } @@ -156,8 +156,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_remove_from_ManyToMany_relationship() { // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); - existingPlaylist.Tracks = _fakers.MusicTrack.Generate(3); + Playlist existingPlaylist = _fakers.Playlist.GenerateOne(); + existingPlaylist.Tracks = _fakers.MusicTrack.GenerateList(3); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -223,12 +223,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - playlistInDatabase.Tracks.ShouldHaveCount(1); + playlistInDatabase.Tracks.Should().HaveCount(1); playlistInDatabase.Tracks[0].Id.Should().Be(existingPlaylist.Tracks[1].Id); List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.ShouldHaveCount(3); + tracksInDatabase.Should().HaveCount(3); }); } @@ -243,7 +243,7 @@ public async Task Cannot_remove_for_href_element() new { op = "remove", - href = "/api/v1/musicTracks/1/relationships/performers" + href = "/api/musicTracks/1/relationships/performers" } } }; @@ -256,15 +256,15 @@ public async Task Cannot_remove_for_href_element() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -295,15 +295,15 @@ public async Task Cannot_remove_for_missing_type_in_ref() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -335,15 +335,15 @@ public async Task Cannot_remove_for_unknown_type_in_ref() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -374,22 +374,22 @@ public async Task Cannot_remove_for_missing_ID_in_ref() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(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.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_remove_for_unknown_ID_in_ref() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -432,13 +432,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); error.Title.Should().Be("The requested resource does not exist."); error.Detail.Should().Be($"Resource of type 'recordCompanies' with ID '{companyId}' does not exist."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); error.Meta.Should().NotContainKey("requestBody"); } @@ -473,15 +473,15 @@ public async Task Cannot_remove_for_ID_and_local_ID_in_ref() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -513,22 +513,22 @@ public async Task Cannot_remove_for_unknown_relationship_in_ref() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Unknown relationship found."); error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'performers'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_remove_for_missing_data() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -561,22 +561,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_remove_for_null_data() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -610,22 +610,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of 'null'."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_remove_for_object_data() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -661,15 +661,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of an object."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -708,15 +708,15 @@ public async Task Cannot_remove_for_missing_type_in_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -756,15 +756,15 @@ public async Task Cannot_remove_for_unknown_type_in_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -803,15 +803,15 @@ public async Task Cannot_remove_for_missing_ID_in_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(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.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -852,28 +852,28 @@ public async Task Cannot_remove_for_ID_and_local_ID_in_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_remove_for_unknown_IDs_in_data() { // Arrange - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); string[] trackIds = - { + [ Unknown.StringId.For(), Unknown.StringId.AltFor() - }; + ]; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -919,20 +919,20 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(2); + responseDocument.Errors.Should().HaveCount(2); ErrorObject error1 = responseDocument.Errors[0]; error1.StatusCode.Should().Be(HttpStatusCode.NotFound); error1.Title.Should().Be("A related resource does not exist."); error1.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[0]}' in relationship 'tracks' does not exist."); - error1.Source.ShouldNotBeNull(); + error1.Source.Should().NotBeNull(); error1.Source.Pointer.Should().Be("/atomic:operations[0]"); ErrorObject error2 = responseDocument.Errors[1]; error2.StatusCode.Should().Be(HttpStatusCode.NotFound); error2.Title.Should().Be("A related resource does not exist."); error2.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[1]}' in relationship 'tracks' does not exist."); - error2.Source.ShouldNotBeNull(); + error2.Source.Should().NotBeNull(); error2.Source.Pointer.Should().Be("/atomic:operations[0]"); } @@ -940,7 +940,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_remove_for_relationship_mismatch_between_ref_and_data() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -981,23 +981,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Conflict); error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); error.Detail.Should().Be("Type 'playlists' is not convertible to type 'performers' of relationship 'performers'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Can_remove_with_empty_data_array() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Performers = _fakers.Performer.Generate(1); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + existingTrack.Performers = _fakers.Performer.GenerateList(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1038,8 +1038,61 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Performers.ShouldHaveCount(1); + trackInDatabase.Performers.Should().HaveCount(1); trackInDatabase.Performers[0].Id.Should().Be(existingTrack.Performers[0].Id); }); } + + [Fact] + public async Task Cannot_remove_with_blocked_capability() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "remove", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "occursIn" + }, + data = new + { + type = "playlists", + id = Unknown.StringId.For() + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Relationship cannot be removed from."); + error.Detail.Should().Be("The relationship 'occursIn' on resource type 'musicTracks' cannot be removed from."); + error.Source.Should().NotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); + error.Meta.Should().HaveRequestBody(); + } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs index faf8a9cb8c..3653763f60 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs @@ -23,8 +23,8 @@ public AtomicReplaceToManyRelationshipTests(IntegrationTestContext { @@ -68,7 +68,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Performers.Should().BeEmpty(); List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.ShouldHaveCount(2); + performersInDatabase.Should().HaveCount(2); }); } @@ -76,8 +76,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_clear_ManyToMany_relationship() { // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); - existingPlaylist.Tracks = _fakers.MusicTrack.Generate(2); + Playlist existingPlaylist = _fakers.Playlist.GenerateOne(); + existingPlaylist.Tracks = _fakers.MusicTrack.GenerateList(2); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -122,7 +122,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.ShouldHaveCount(2); + tracksInDatabase.Should().HaveCount(2); }); } @@ -130,10 +130,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_replace_OneToMany_relationship() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Performers = _fakers.Performer.Generate(1); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + existingTrack.Performers = _fakers.Performer.GenerateList(1); - List existingPerformers = _fakers.Performer.Generate(2); + List existingPerformers = _fakers.Performer.GenerateList(2); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -187,12 +187,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Performers.ShouldHaveCount(2); + trackInDatabase.Performers.Should().HaveCount(2); trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[0].Id); trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[1].Id); List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.ShouldHaveCount(3); + performersInDatabase.Should().HaveCount(3); }); } @@ -200,10 +200,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_replace_ManyToMany_relationship() { // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); - existingPlaylist.Tracks = _fakers.MusicTrack.Generate(1); + Playlist existingPlaylist = _fakers.Playlist.GenerateOne(); + existingPlaylist.Tracks = _fakers.MusicTrack.GenerateList(1); - List existingTracks = _fakers.MusicTrack.Generate(2); + List existingTracks = _fakers.MusicTrack.GenerateList(2); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -257,13 +257,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - playlistInDatabase.Tracks.ShouldHaveCount(2); + playlistInDatabase.Tracks.Should().HaveCount(2); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.ShouldHaveCount(3); + tracksInDatabase.Should().HaveCount(3); }); } @@ -278,7 +278,7 @@ public async Task Cannot_replace_for_href_element() new { op = "update", - href = "/api/v1/musicTracks/1/relationships/performers" + href = "/api/musicTracks/1/relationships/performers" } } }; @@ -291,15 +291,15 @@ public async Task Cannot_replace_for_href_element() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -330,15 +330,15 @@ public async Task Cannot_replace_for_missing_type_in_ref() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -370,15 +370,15 @@ public async Task Cannot_replace_for_unknown_type_in_ref() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -409,22 +409,22 @@ public async Task Cannot_replace_for_missing_ID_in_ref() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(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.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_replace_for_unknown_ID_in_ref() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -467,13 +467,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); error.Title.Should().Be("The requested resource does not exist."); error.Detail.Should().Be($"Resource of type 'recordCompanies' with ID '{companyId}' does not exist."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); error.Meta.Should().NotContainKey("requestBody"); } @@ -484,7 +484,7 @@ public async Task Cannot_replace_for_incompatible_ID_in_ref() // Arrange string guid = Unknown.StringId.Guid; - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -525,15 +525,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Incompatible 'id' value found."); error.Detail.Should().Be($"Failed to convert '{guid}' of type 'String' to type 'Int16'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/id"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -566,15 +566,15 @@ public async Task Cannot_replace_for_ID_and_local_ID_in_ref() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -606,22 +606,22 @@ public async Task Cannot_replace_for_unknown_relationship_in_ref() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Unknown relationship found."); error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'performers'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_replace_for_missing_data() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -654,22 +654,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_replace_for_null_data() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -703,22 +703,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of 'null'."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_replace_for_object_data() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -754,15 +754,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of an object."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -801,15 +801,15 @@ public async Task Cannot_replace_for_missing_type_in_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -849,15 +849,15 @@ public async Task Cannot_replace_for_unknown_type_in_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -896,15 +896,15 @@ public async Task Cannot_replace_for_missing_ID_in_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(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.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -945,28 +945,28 @@ public async Task Cannot_replace_for_ID_and_local_ID_in_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_replace_for_unknown_IDs_in_data() { // Arrange - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); string[] trackIds = - { + [ Unknown.StringId.For(), Unknown.StringId.AltFor() - }; + ]; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1012,20 +1012,20 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(2); + responseDocument.Errors.Should().HaveCount(2); ErrorObject error1 = responseDocument.Errors[0]; error1.StatusCode.Should().Be(HttpStatusCode.NotFound); error1.Title.Should().Be("A related resource does not exist."); error1.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[0]}' in relationship 'tracks' does not exist."); - error1.Source.ShouldNotBeNull(); + error1.Source.Should().NotBeNull(); error1.Source.Pointer.Should().Be("/atomic:operations[0]"); ErrorObject error2 = responseDocument.Errors[1]; error2.StatusCode.Should().Be(HttpStatusCode.NotFound); error2.Title.Should().Be("A related resource does not exist."); error2.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[1]}' in relationship 'tracks' does not exist."); - error2.Source.ShouldNotBeNull(); + error2.Source.Should().NotBeNull(); error2.Source.Pointer.Should().Be("/atomic:operations[0]"); } @@ -1033,7 +1033,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_replace_for_incompatible_ID_in_data() { // Arrange - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1074,22 +1074,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Incompatible 'id' value found."); error.Detail.Should().Be("Failed to convert 'invalid-guid' of type 'String' to type 'Guid'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/id"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_replace_for_relationship_mismatch_between_ref_and_data() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1130,14 +1130,67 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Conflict); error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); error.Detail.Should().Be("Type 'playlists' is not convertible to type 'performers' of relationship 'performers'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data[0]/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); + } + + [Fact] + public async Task Cannot_assign_relationship_with_blocked_capability() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationship = "occursIn" + }, + data = new + { + type = "playlists", + id = Unknown.StringId.For() + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Relationship cannot be assigned."); + error.Detail.Should().Be("The relationship 'occursIn' on resource type 'musicTracks' cannot be assigned to."); + error.Source.Should().NotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); + error.Meta.Should().HaveRequestBody(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs index 4b650a3d85..a079f9d829 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs @@ -23,8 +23,8 @@ public AtomicUpdateToOneRelationshipTests(IntegrationTestContext { @@ -68,7 +68,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => lyricInDatabase.Track.Should().BeNull(); List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.ShouldHaveCount(1); + tracksInDatabase.Should().HaveCount(1); }); } @@ -76,8 +76,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_clear_OneToOne_relationship_from_dependent_side() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Lyric = _fakers.Lyric.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + existingTrack.Lyric = _fakers.Lyric.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -121,7 +121,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Lyric.Should().BeNull(); List lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); - lyricsInDatabase.ShouldHaveCount(1); + lyricsInDatabase.Should().HaveCount(1); }); } @@ -129,8 +129,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_clear_ManyToOne_relationship() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + existingTrack.OwnedBy = _fakers.RecordCompany.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -174,7 +174,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.OwnedBy.Should().BeNull(); List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.ShouldHaveCount(1); + companiesInDatabase.Should().HaveCount(1); }); } @@ -182,8 +182,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_OneToOne_relationship_from_principal_side() { // Arrange - Lyric existingLyric = _fakers.Lyric.Generate(); - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + Lyric existingLyric = _fakers.Lyric.GenerateOne(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -227,7 +227,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); - lyricInDatabase.Track.ShouldNotBeNull(); + lyricInDatabase.Track.Should().NotBeNull(); lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); }); } @@ -236,8 +236,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_OneToOne_relationship_from_dependent_side() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - Lyric existingLyric = _fakers.Lyric.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + Lyric existingLyric = _fakers.Lyric.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -281,7 +281,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Lyric.ShouldNotBeNull(); + trackInDatabase.Lyric.Should().NotBeNull(); trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); }); } @@ -290,8 +290,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_ManyToOne_relationship() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -335,7 +335,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.OwnedBy.ShouldNotBeNull(); + trackInDatabase.OwnedBy.Should().NotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); }); } @@ -344,10 +344,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_replace_OneToOne_relationship_from_principal_side() { // Arrange - Lyric existingLyric = _fakers.Lyric.Generate(); - existingLyric.Track = _fakers.MusicTrack.Generate(); + Lyric existingLyric = _fakers.Lyric.GenerateOne(); + existingLyric.Track = _fakers.MusicTrack.GenerateOne(); - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -392,11 +392,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); - lyricInDatabase.Track.ShouldNotBeNull(); + lyricInDatabase.Track.Should().NotBeNull(); lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.ShouldHaveCount(2); + tracksInDatabase.Should().HaveCount(2); }); } @@ -404,10 +404,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_replace_OneToOne_relationship_from_dependent_side() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Lyric = _fakers.Lyric.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + existingTrack.Lyric = _fakers.Lyric.GenerateOne(); - Lyric existingLyric = _fakers.Lyric.Generate(); + Lyric existingLyric = _fakers.Lyric.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -452,11 +452,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Lyric.ShouldNotBeNull(); + trackInDatabase.Lyric.Should().NotBeNull(); trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); List lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); - lyricsInDatabase.ShouldHaveCount(2); + lyricsInDatabase.Should().HaveCount(2); }); } @@ -464,10 +464,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_replace_ManyToOne_relationship() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + existingTrack.OwnedBy = _fakers.RecordCompany.GenerateOne(); - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -512,11 +512,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.OwnedBy.ShouldNotBeNull(); + trackInDatabase.OwnedBy.Should().NotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.ShouldHaveCount(2); + companiesInDatabase.Should().HaveCount(2); }); } @@ -531,7 +531,7 @@ public async Task Cannot_create_for_href_element() new { op = "update", - href = "/api/v1/musicTracks/1/relationships/ownedBy" + href = "/api/musicTracks/1/relationships/ownedBy" } } }; @@ -544,15 +544,15 @@ public async Task Cannot_create_for_href_element() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -583,15 +583,15 @@ public async Task Cannot_create_for_missing_type_in_ref() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -623,15 +623,15 @@ public async Task Cannot_create_for_unknown_type_in_ref() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -662,15 +662,15 @@ public async Task Cannot_create_for_missing_ID_in_ref() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(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.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -679,7 +679,7 @@ public async Task Cannot_create_for_unknown_ID_in_ref() // Arrange string trackId = Unknown.StringId.For(); - Lyric existingLyric = _fakers.Lyric.Generate(); + Lyric existingLyric = _fakers.Lyric.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -717,13 +717,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); error.Title.Should().Be("The requested resource does not exist."); error.Detail.Should().Be($"Resource of type 'musicTracks' with ID '{trackId}' does not exist."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); error.Meta.Should().NotContainKey("requestBody"); } @@ -732,7 +732,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_create_for_incompatible_ID_in_ref() { // Arrange - Lyric existingLyric = _fakers.Lyric.Generate(); + Lyric existingLyric = _fakers.Lyric.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -770,15 +770,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Incompatible 'id' value found."); error.Detail.Should().Be("Failed to convert 'invalid-guid' of type 'String' to type 'Guid'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/id"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -811,15 +811,15 @@ public async Task Cannot_create_for_ID_and_local_ID_in_ref() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -851,22 +851,22 @@ public async Task Cannot_create_for_unknown_relationship_in_ref() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Unknown relationship found."); error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'performers'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_create_for_missing_data() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -899,22 +899,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_create_for_array_data() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -955,15 +955,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Expected an object or 'null', instead of an array."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -999,15 +999,15 @@ public async Task Cannot_create_for_missing_type_in_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -1044,15 +1044,15 @@ public async Task Cannot_create_for_unknown_type_in_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -1088,15 +1088,15 @@ public async Task Cannot_create_for_missing_ID_in_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(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.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -1134,22 +1134,22 @@ public async Task Cannot_create_for_ID_and_local_ID_in_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_create_for_unknown_ID_in_data() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1189,13 +1189,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); error.Title.Should().Be("A related resource does not exist."); error.Detail.Should().Be($"Related resource of type 'lyrics' with ID '{lyricId}' in relationship 'lyric' does not exist."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); error.Meta.Should().NotContainKey("requestBody"); } @@ -1204,7 +1204,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_create_for_incompatible_ID_in_data() { // Arrange - Lyric existingLyric = _fakers.Lyric.Generate(); + Lyric existingLyric = _fakers.Lyric.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1242,22 +1242,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Incompatible 'id' value found."); error.Detail.Should().Be("Failed to convert 'invalid-guid' of type 'String' to type 'Guid'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/id"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_create_for_relationship_mismatch_between_ref_and_data() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1295,14 +1295,67 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Conflict); error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); error.Detail.Should().Be("Type 'playlists' is not convertible to type 'lyrics' of relationship 'lyric'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); + } + + [Fact] + public async Task Cannot_assign_relationship_with_blocked_capability() + { + // Arrange + Lyric existingLyric = _fakers.Lyric.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Lyrics.Add(existingLyric); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + @ref = new + { + type = "lyrics", + id = existingLyric.StringId, + relationship = "language" + }, + data = new + { + type = "textLanguages", + id = Unknown.StringId.For() + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Relationship cannot be assigned."); + error.Detail.Should().Be("The relationship 'language' on resource type 'lyrics' cannot be assigned to."); + error.Source.Should().NotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/relationship"); + error.Meta.Should().HaveRequestBody(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs index fa801c67c1..32db3a5221 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs @@ -23,8 +23,8 @@ public AtomicReplaceToManyRelationshipTests(IntegrationTestContext { @@ -73,7 +73,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Performers.Should().BeEmpty(); List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.ShouldHaveCount(2); + performersInDatabase.Should().HaveCount(2); }); } @@ -81,8 +81,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_clear_ManyToMany_relationship() { // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); - existingPlaylist.Tracks = _fakers.MusicTrack.Generate(2); + Playlist existingPlaylist = _fakers.Playlist.GenerateOne(); + existingPlaylist.Tracks = _fakers.MusicTrack.GenerateList(2); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -132,7 +132,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.ShouldHaveCount(2); + tracksInDatabase.Should().HaveCount(2); }); } @@ -140,10 +140,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_replace_OneToMany_relationship() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Performers = _fakers.Performer.Generate(1); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + existingTrack.Performers = _fakers.Performer.GenerateList(1); - List existingPerformers = _fakers.Performer.Generate(2); + List existingPerformers = _fakers.Performer.GenerateList(2); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -202,12 +202,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Performers.ShouldHaveCount(2); + trackInDatabase.Performers.Should().HaveCount(2); trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[0].Id); trackInDatabase.Performers.Should().ContainSingle(performer => performer.Id == existingPerformers[1].Id); List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.ShouldHaveCount(3); + performersInDatabase.Should().HaveCount(3); }); } @@ -215,10 +215,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_replace_ManyToMany_relationship() { // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); - existingPlaylist.Tracks = _fakers.MusicTrack.Generate(1); + Playlist existingPlaylist = _fakers.Playlist.GenerateOne(); + existingPlaylist.Tracks = _fakers.MusicTrack.GenerateList(1); - List existingTracks = _fakers.MusicTrack.Generate(2); + List existingTracks = _fakers.MusicTrack.GenerateList(2); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -277,13 +277,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Playlist playlistInDatabase = await dbContext.Playlists.Include(playlist => playlist.Tracks).FirstWithIdAsync(existingPlaylist.Id); - playlistInDatabase.Tracks.ShouldHaveCount(2); + playlistInDatabase.Tracks.Should().HaveCount(2); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[0].Id); playlistInDatabase.Tracks.Should().ContainSingle(musicTrack => musicTrack.Id == existingTracks[1].Id); List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.ShouldHaveCount(3); + tracksInDatabase.Should().HaveCount(3); }); } @@ -291,7 +291,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_replace_for_missing_data_in_relationship() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -329,22 +329,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_replace_for_null_data_in_relationship() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -383,22 +383,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of 'null'."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_replace_for_object_data_in_relationship() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -439,15 +439,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Expected an array, instead of an object."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -491,15 +491,15 @@ public async Task Cannot_replace_for_missing_type_in_relationship_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/tracks/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -544,15 +544,15 @@ public async Task Cannot_replace_for_unknown_type_in_relationship_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -596,15 +596,15 @@ public async Task Cannot_replace_for_missing_ID_in_relationship_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(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.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -650,28 +650,28 @@ public async Task Cannot_replace_for_ID_and_local_ID_relationship_in_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_replace_for_unknown_IDs_in_relationship_data() { // Arrange - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); string[] trackIds = - { + [ Unknown.StringId.For(), Unknown.StringId.AltFor() - }; + ]; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -722,20 +722,20 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(2); + responseDocument.Errors.Should().HaveCount(2); ErrorObject error1 = responseDocument.Errors[0]; error1.StatusCode.Should().Be(HttpStatusCode.NotFound); error1.Title.Should().Be("A related resource does not exist."); error1.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[0]}' in relationship 'tracks' does not exist."); - error1.Source.ShouldNotBeNull(); + error1.Source.Should().NotBeNull(); error1.Source.Pointer.Should().Be("/atomic:operations[0]"); ErrorObject error2 = responseDocument.Errors[1]; error2.StatusCode.Should().Be(HttpStatusCode.NotFound); error2.Title.Should().Be("A related resource does not exist."); error2.Detail.Should().Be($"Related resource of type 'musicTracks' with ID '{trackIds[1]}' in relationship 'tracks' does not exist."); - error2.Source.ShouldNotBeNull(); + error2.Source.Should().NotBeNull(); error2.Source.Pointer.Should().Be("/atomic:operations[0]"); } @@ -743,7 +743,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_create_for_relationship_mismatch() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -789,14 +789,75 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Conflict); error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); error.Detail.Should().Be("Type 'playlists' is not convertible to type 'performers' of relationship 'performers'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/performers/data[0]/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); + } + + [Fact] + public async Task Cannot_assign_relationship_with_blocked_capability() + { + // Arrange + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.MusicTracks.Add(existingTrack); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "musicTracks", + id = existingTrack.StringId, + relationships = new + { + occursIn = new + { + data = new[] + { + new + { + type = "playlists", + id = Unknown.StringId.For() + } + } + } + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Relationship cannot be assigned."); + error.Detail.Should().Be("The relationship 'occursIn' on resource type 'musicTracks' cannot be assigned to."); + error.Source.Should().NotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/occursIn"); + error.Meta.Should().HaveRequestBody(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs index 336b7d5621..8bf1ae451b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs @@ -24,7 +24,7 @@ public AtomicUpdateResourceTests(IntegrationTestContext(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceDefinition(); @@ -41,8 +41,8 @@ public async Task Can_update_resources() // Arrange const int elementCount = 5; - List existingTracks = _fakers.MusicTrack.Generate(elementCount); - string[] newTrackTitles = _fakers.MusicTrack.Generate(elementCount).Select(musicTrack => musicTrack.Title).ToArray(); + List existingTracks = _fakers.MusicTrack.GenerateList(elementCount); + string[] newTrackTitles = _fakers.MusicTrack.GenerateList(elementCount).Select(musicTrack => musicTrack.Title).ToArray(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -89,7 +89,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.ShouldHaveCount(elementCount); + tracksInDatabase.Should().HaveCount(elementCount); for (int index = 0; index < elementCount; index++) { @@ -105,8 +105,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_update_resource_without_attributes_or_relationships() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + existingTrack.OwnedBy = _fakers.RecordCompany.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -153,7 +153,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Title.Should().Be(existingTrack.Title); trackInDatabase.Genre.Should().Be(existingTrack.Genre); - trackInDatabase.OwnedBy.ShouldNotBeNull(); + trackInDatabase.OwnedBy.Should().NotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(existingTrack.OwnedBy.Id); }); } @@ -162,8 +162,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_update_resource_with_unknown_attribute() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - string newTitle = _fakers.MusicTrack.Generate().Title; + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + string newTitle = _fakers.MusicTrack.GenerateOne().Title; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -200,15 +200,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Unknown attribute found."); error.Detail.Should().Be("Attribute 'doesNotExist' does not exist on resource type 'musicTracks'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/doesNotExist"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -218,8 +218,8 @@ public async Task Can_update_resource_with_unknown_attribute() var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); options.AllowUnknownFieldsInRequestBody = true; - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - string newTitle = _fakers.MusicTrack.Generate().Title; + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + string newTitle = _fakers.MusicTrack.GenerateOne().Title; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -270,7 +270,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_update_resource_with_unknown_relationship() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -313,15 +313,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Unknown relationship found."); error.Detail.Should().Be("Relationship 'doesNotExist' does not exist on resource type 'musicTracks'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/doesNotExist"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -331,7 +331,7 @@ public async Task Can_update_resource_with_unknown_relationship() var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); options.AllowUnknownFieldsInRequestBody = true; - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -381,10 +381,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_partially_update_resource_without_side_effects() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + existingTrack.OwnedBy = _fakers.RecordCompany.GenerateOne(); - string newGenre = _fakers.MusicTrack.Generate().Genre!; + string newGenre = _fakers.MusicTrack.GenerateOne().Genre!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -431,7 +431,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Genre.Should().Be(newGenre); trackInDatabase.ReleasedAt.Should().Be(existingTrack.ReleasedAt); - trackInDatabase.OwnedBy.ShouldNotBeNull(); + trackInDatabase.OwnedBy.Should().NotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(existingTrack.OwnedBy.Id); }); } @@ -440,13 +440,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_completely_update_resource_without_side_effects() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + existingTrack.OwnedBy = _fakers.RecordCompany.GenerateOne(); - string newTitle = _fakers.MusicTrack.Generate().Title; - decimal? newLengthInSeconds = _fakers.MusicTrack.Generate().LengthInSeconds; - string newGenre = _fakers.MusicTrack.Generate().Genre!; - DateTimeOffset newReleasedAt = _fakers.MusicTrack.Generate().ReleasedAt; + string newTitle = _fakers.MusicTrack.GenerateOne().Title; + decimal? newLengthInSeconds = _fakers.MusicTrack.GenerateOne().LengthInSeconds; + string newGenre = _fakers.MusicTrack.GenerateOne().Genre!; + DateTimeOffset newReleasedAt = _fakers.MusicTrack.GenerateOne().ReleasedAt; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -496,7 +496,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Genre.Should().Be(newGenre); trackInDatabase.ReleasedAt.Should().Be(newReleasedAt); - trackInDatabase.OwnedBy.ShouldNotBeNull(); + trackInDatabase.OwnedBy.Should().NotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(existingTrack.OwnedBy.Id); }); } @@ -505,8 +505,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_update_resource_with_side_effects() { // Arrange - TextLanguage existingLanguage = _fakers.TextLanguage.Generate(); - string newIsoCode = _fakers.TextLanguage.Generate().IsoCode!; + TextLanguage existingLanguage = _fakers.TextLanguage.GenerateOne(); + string newIsoCode = _fakers.TextLanguage.GenerateOne().IsoCode!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -542,16 +542,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.Should().HaveCount(1); string isoCode = $"{newIsoCode}{ImplicitlyChangingTextLanguageDefinition.Suffix}"; - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { resource.Type.Should().Be("textLanguages"); - resource.Attributes.ShouldContainKey("isoCode").With(value => value.Should().Be(isoCode)); + resource.Attributes.Should().ContainKey("isoCode").WhoseValue.Should().Be(isoCode); resource.Attributes.Should().NotContainKey("isRightToLeft"); - resource.Relationships.ShouldNotBeEmpty(); + resource.Relationships.Should().NotBeEmpty(); }); await _testContext.RunOnDatabaseAsync(async dbContext => @@ -565,8 +565,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Update_resource_with_side_effects_hides_relationship_data_in_response() { // Arrange - TextLanguage existingLanguage = _fakers.TextLanguage.Generate(); - existingLanguage.Lyrics = _fakers.Lyric.Generate(1); + TextLanguage existingLanguage = _fakers.TextLanguage.GenerateOne(); + existingLanguage.Lyrics = _fakers.Lyric.GenerateList(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -598,11 +598,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Results.ShouldHaveCount(1); + responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + responseDocument.Results[0].Data.SingleValue.RefShould().NotBeNull().And.Subject.With(resource => { - resource.Relationships.ShouldNotBeEmpty(); + resource.Relationships.Should().NotBeEmpty(); resource.Relationships.Values.Should().OnlyContain(value => value != null && value.Data.Value == null); }); } @@ -618,7 +618,7 @@ public async Task Cannot_update_resource_for_href_element() new { op = "update", - href = "/api/v1/musicTracks/1" + href = "/api/musicTracks/1" } } }; @@ -631,23 +631,23 @@ public async Task Cannot_update_resource_for_href_element() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'href' element is not supported."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/href"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Can_update_resource_for_ref_element() { // Arrange - Performer existingPerformer = _fakers.Performer.Generate(); - string newArtistName = _fakers.Performer.Generate().ArtistName!; + Performer existingPerformer = _fakers.Performer.GenerateOne(); + string newArtistName = _fakers.Performer.GenerateOne().ArtistName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -737,15 +737,15 @@ public async Task Cannot_update_resource_for_missing_type_in_ref() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -786,15 +786,15 @@ public async Task Cannot_update_resource_for_missing_ID_in_ref() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(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.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -837,15 +837,15 @@ public async Task Cannot_update_resource_for_ID_and_local_ID_in_ref() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -871,15 +871,15 @@ public async Task Cannot_update_resource_for_missing_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -906,22 +906,22 @@ public async Task Cannot_update_resource_for_null_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_update_resource_for_array_data() { // Arrange - Performer existingPerformer = _fakers.Performer.Generate(); + Performer existingPerformer = _fakers.Performer.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -960,15 +960,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of an array."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -1004,15 +1004,15 @@ public async Task Cannot_update_resource_for_missing_type_in_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -1048,15 +1048,15 @@ public async Task Cannot_update_resource_for_missing_ID_in_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(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.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -1094,15 +1094,15 @@ public async Task Cannot_update_resource_for_ID_and_local_ID_in_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -1144,15 +1144,15 @@ public async Task Cannot_update_on_resource_type_mismatch_between_ref_and_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Conflict); error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); error.Detail.Should().Be("Type 'playlists' is not convertible to type 'performers'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -1197,15 +1197,15 @@ public async Task Cannot_update_on_resource_ID_mismatch_between_ref_and_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Conflict); error.Title.Should().Be("Failed to deserialize request body: Conflicting 'id' values found."); error.Detail.Should().Be($"Expected '{performerId1}' instead of '{performerId2}'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/id"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -1247,15 +1247,15 @@ public async Task Cannot_update_on_resource_local_ID_mismatch_between_ref_and_da // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Conflict); error.Title.Should().Be("Failed to deserialize request body: Conflicting 'lid' values found."); error.Detail.Should().Be("Expected 'local-1' instead of 'local-2'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/lid"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -1299,15 +1299,15 @@ public async Task Cannot_update_on_mixture_of_ID_and_local_ID_between_ref_and_da // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'id' element is required."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -1351,15 +1351,15 @@ public async Task Cannot_update_on_mixture_of_local_ID_and_ID_between_ref_and_da // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'lid' element is required."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -1396,15 +1396,15 @@ public async Task Cannot_update_resource_for_unknown_type() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -1443,13 +1443,13 @@ public async Task Cannot_update_resource_for_unknown_ID() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); error.Title.Should().Be("The requested resource does not exist."); error.Detail.Should().Be($"Resource of type 'performers' with ID '{performerId}' does not exist."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); error.Meta.Should().NotContainKey("requestBody"); } @@ -1492,73 +1492,22 @@ public async Task Cannot_update_resource_for_incompatible_ID() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Incompatible 'id' value found."); error.Detail.Should().Be($"Failed to convert '{guid}' of type 'String' to type 'Int32'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/ref/id"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); - } - - [Fact] - public async Task Cannot_update_resource_attribute_with_blocked_capability() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Lyrics.Add(existingLyric); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "lyrics", - id = existingLyric.StringId, - attributes = new - { - createdAt = 12.July(1980) - } - } - } - } - }; - - 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: Attribute value cannot be assigned when updating resource."); - error.Detail.Should().Be("The attribute 'createdAt' on resource type 'lyrics' cannot be assigned to."); - error.Source.ShouldNotBeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/createdAt"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_update_resource_with_readonly_attribute() { // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); + Playlist existingPlaylist = _fakers.Playlist.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1594,22 +1543,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Attribute is read-only."); error.Detail.Should().Be("Attribute 'isArchived' on resource type 'playlists' is read-only."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/isArchived"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_change_ID_of_existing_resource() { // Arrange - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1645,22 +1594,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Resource ID is read-only."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/id"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_update_resource_with_incompatible_attribute_value() { // Arrange - Performer existingPerformer = _fakers.Performer.Generate(); + Performer existingPerformer = _fakers.Performer.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1696,31 +1645,31 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Incompatible attribute value found."); error.Detail.Should().Be("Failed to convert attribute 'bornAt' with value '123.45' of type 'Number' to type 'DateTimeOffset'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/bornAt"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Can_update_resource_with_attributes_and_multiple_relationship_types() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Lyric = _fakers.Lyric.Generate(); - existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); - existingTrack.Performers = _fakers.Performer.Generate(1); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + existingTrack.Lyric = _fakers.Lyric.GenerateOne(); + existingTrack.OwnedBy = _fakers.RecordCompany.GenerateOne(); + existingTrack.Performers = _fakers.Performer.GenerateList(1); - string newGenre = _fakers.MusicTrack.Generate().Genre!; + string newGenre = _fakers.MusicTrack.GenerateOne().Genre!; - Lyric existingLyric = _fakers.Lyric.Generate(); - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - Performer existingPerformer = _fakers.Performer.Generate(); + Lyric existingLyric = _fakers.Lyric.GenerateOne(); + RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); + Performer existingPerformer = _fakers.Performer.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1791,7 +1740,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) @@ -1799,20 +1748,71 @@ 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); trackInDatabase.Genre.Should().Be(newGenre); - trackInDatabase.Lyric.ShouldNotBeNull(); + trackInDatabase.Lyric.Should().NotBeNull(); trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); - trackInDatabase.OwnedBy.ShouldNotBeNull(); + trackInDatabase.OwnedBy.Should().NotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); - trackInDatabase.Performers.ShouldHaveCount(1); + trackInDatabase.Performers.Should().HaveCount(1); trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); }); } + + [Fact] + public async Task Cannot_assign_attribute_with_blocked_capability() + { + // Arrange + Lyric existingLyric = _fakers.Lyric.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Lyrics.Add(existingLyric); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "lyrics", + id = existingLyric.StringId, + attributes = new + { + createdAt = 12.July(1980) + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Attribute value cannot be assigned when updating resource."); + error.Detail.Should().Be("The attribute 'createdAt' on resource type 'lyrics' cannot be assigned to."); + error.Source.Should().NotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/attributes/createdAt"); + error.Meta.Should().HaveRequestBody(); + } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs index 3996983042..a8c6881616 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs @@ -23,8 +23,8 @@ public AtomicUpdateToOneRelationshipTests(IntegrationTestContext { @@ -73,7 +73,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => lyricInDatabase.Track.Should().BeNull(); List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.ShouldHaveCount(1); + tracksInDatabase.Should().HaveCount(1); }); } @@ -81,8 +81,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_clear_OneToOne_relationship_from_dependent_side() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Lyric = _fakers.Lyric.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + existingTrack.Lyric = _fakers.Lyric.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -131,7 +131,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.Lyric.Should().BeNull(); List lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); - lyricsInDatabase.ShouldHaveCount(1); + lyricsInDatabase.Should().HaveCount(1); }); } @@ -139,8 +139,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_clear_ManyToOne_relationship() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + existingTrack.OwnedBy = _fakers.RecordCompany.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -189,7 +189,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => trackInDatabase.OwnedBy.Should().BeNull(); List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.ShouldHaveCount(1); + companiesInDatabase.Should().HaveCount(1); }); } @@ -197,8 +197,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_OneToOne_relationship_from_principal_side() { // Arrange - Lyric existingLyric = _fakers.Lyric.Generate(); - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + Lyric existingLyric = _fakers.Lyric.GenerateOne(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -247,7 +247,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); - lyricInDatabase.Track.ShouldNotBeNull(); + lyricInDatabase.Track.Should().NotBeNull(); lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); }); } @@ -256,8 +256,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_OneToOne_relationship_from_dependent_side() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - Lyric existingLyric = _fakers.Lyric.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + Lyric existingLyric = _fakers.Lyric.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -306,7 +306,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Lyric.ShouldNotBeNull(); + trackInDatabase.Lyric.Should().NotBeNull(); trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); }); } @@ -315,8 +315,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_ManyToOne_relationship() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -365,7 +365,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.OwnedBy.ShouldNotBeNull(); + trackInDatabase.OwnedBy.Should().NotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); }); } @@ -374,10 +374,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_replace_OneToOne_relationship_from_principal_side() { // Arrange - Lyric existingLyric = _fakers.Lyric.Generate(); - existingLyric.Track = _fakers.MusicTrack.Generate(); + Lyric existingLyric = _fakers.Lyric.GenerateOne(); + existingLyric.Track = _fakers.MusicTrack.GenerateOne(); - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -427,11 +427,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); - lyricInDatabase.Track.ShouldNotBeNull(); + lyricInDatabase.Track.Should().NotBeNull(); lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.ShouldHaveCount(2); + tracksInDatabase.Should().HaveCount(2); }); } @@ -439,10 +439,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_replace_OneToOne_relationship_from_dependent_side() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Lyric = _fakers.Lyric.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + existingTrack.Lyric = _fakers.Lyric.GenerateOne(); - Lyric existingLyric = _fakers.Lyric.Generate(); + Lyric existingLyric = _fakers.Lyric.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -492,11 +492,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.Lyric.ShouldNotBeNull(); + trackInDatabase.Lyric.Should().NotBeNull(); trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); List lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); - lyricsInDatabase.ShouldHaveCount(2); + lyricsInDatabase.Should().HaveCount(2); }); } @@ -504,10 +504,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_replace_ManyToOne_relationship() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); + existingTrack.OwnedBy = _fakers.RecordCompany.GenerateOne(); - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); + RecordCompany existingCompany = _fakers.RecordCompany.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -557,11 +557,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - trackInDatabase.OwnedBy.ShouldNotBeNull(); + trackInDatabase.OwnedBy.Should().NotBeNull(); trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.ShouldHaveCount(2); + companiesInDatabase.Should().HaveCount(2); }); } @@ -569,7 +569,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_create_for_null_relationship() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -605,22 +605,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Expected an object, instead of 'null'."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_create_for_missing_data_in_relationship() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -658,22 +658,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'data' element is required."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_create_for_array_data_in_relationship() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -719,15 +719,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Expected an object or 'null', instead of an array."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -768,15 +768,15 @@ public async Task Cannot_create_for_missing_type_in_relationship_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'type' element is required."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/track/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -818,15 +818,15 @@ public async Task Cannot_create_for_unknown_type_in_relationship_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); error.Detail.Should().Be($"Resource type '{Unknown.ResourceType}' does not exist."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -867,15 +867,15 @@ public async Task Cannot_create_for_missing_ID_in_relationship_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(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.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] @@ -918,22 +918,22 @@ public async Task Cannot_create_for_ID_and_local_ID_in_relationship_data() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: The 'id' and 'lid' element are mutually exclusive."); error.Detail.Should().BeNull(); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); } [Fact] public async Task Cannot_create_for_unknown_ID_in_relationship_data() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -978,13 +978,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); error.Title.Should().Be("A related resource does not exist."); error.Detail.Should().Be($"Related resource of type 'lyrics' with ID '{lyricId}' in relationship 'lyric' does not exist."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]"); error.Meta.Should().NotContainKey("requestBody"); } @@ -993,7 +993,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_create_for_relationship_mismatch() { // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); + MusicTrack existingTrack = _fakers.MusicTrack.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1036,14 +1036,72 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Conflict); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Conflict); error.Title.Should().Be("Failed to deserialize request body: Incompatible resource type found."); error.Detail.Should().Be("Type 'playlists' is not convertible to type 'lyrics' of relationship 'lyric'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/lyric/data/type"); - error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + error.Meta.Should().HaveRequestBody(); + } + + [Fact] + public async Task Cannot_assign_relationship_with_blocked_capability() + { + // Arrange + Lyric existingLyric = _fakers.Lyric.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Lyrics.Add(existingLyric); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "update", + data = new + { + type = "lyrics", + id = existingLyric.StringId, + relationships = new + { + language = new + { + data = new + { + type = "textLanguages", + id = Unknown.StringId.For() + } + } + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: Relationship cannot be assigned."); + error.Detail.Should().Be("The relationship 'language' on resource type 'lyrics' cannot be assigned to."); + error.Source.Should().NotBeNull(); + error.Source.Pointer.Should().Be("/atomic:operations[0]/data/relationships/language"); + error.Meta.Should().HaveRequestBody(); } } 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..547c008afa --- /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 = []; + + 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..d7f252c3bd --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/OperationsController.cs @@ -0,0 +1,50 @@ +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( + IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request, + ITargetedFields targetedFields, IAtomicOperationFilter operationFilter) + : JsonApiOperationsController(options, resourceGraph, loggerFactory, processor, request, targetedFields, operationFilter) +{ + 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..d8834ce7fa --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopeOperationsTests.cs @@ -0,0 +1,472 @@ +using System.Net; +using System.Net.Http.Headers; +using FluentAssertions; +using JsonApiDotNetCore.Middleware; +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.GenerateOne(); + Movie newMovie = _fakers.Movie.GenerateOne(); + + 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.Should().HaveCount(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.Should().NotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_create_resource_with_read_scope() + { + // Arrange + Genre newGenre = _fakers.Genre.GenerateOne(); + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "genres", + attributes = new + { + name = newGenre.Name + } + } + } + } + }; + + const string route = "/operations"; + string contentType = JsonApiMediaType.AtomicOperations.ToString(); + + Action setRequestHeaders = headers => + { + headers.Add(ScopeHeaderName, "read:genres"); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(JsonApiMediaType.AtomicOperations.ToString())); + }; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = + await _testContext.ExecutePostAsync(route, requestBody, contentType, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Unauthorized); + + responseDocument.Errors.Should().HaveCount(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.Should().NotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_update_resources_without_scopes() + { + // Arrange + string newTitle = _fakers.Movie.GenerateOne().Title; + DateTime newBornAt = _fakers.Actor.GenerateOne().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.Should().HaveCount(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.Should().NotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_update_resource_with_relationships_without_scopes() + { + // Arrange + string newTitle = _fakers.Movie.GenerateOne().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.Should().HaveCount(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.Should().NotBeNull(); + 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.Should().HaveCount(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.Should().NotBeNull(); + 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.Should().HaveCount(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.Should().NotBeNull(); + 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.Should().HaveCount(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.Should().NotBeNull(); + 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.Should().HaveCount(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.Should().NotBeNull(); + 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.Should().HaveCount(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.Should().NotBeNull(); + 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..91530044df --- /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.Should().HaveCount(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.Should().NotBeNull(); + 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.Should().HaveCount(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.Should().NotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Can_get_primary_resources_with_correct_scope() + { + // Arrange + Movie movie = _fakers.Movie.GenerateOne(); + movie.Genre = _fakers.Genre.GenerateOne(); + + 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.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("movies"); + responseDocument.Data.ManyValue[0].Id.Should().Be(movie.StringId); + responseDocument.Data.ManyValue[0].Attributes.Should().NotBeEmpty(); + responseDocument.Data.ManyValue[0].Relationships.Should().NotBeEmpty(); + } + + [Fact] + public async Task Can_get_primary_resources_with_write_scope() + { + // Arrange + Genre genre = _fakers.Genre.GenerateOne(); + + 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.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("genres"); + responseDocument.Data.ManyValue[0].Id.Should().Be(genre.StringId); + responseDocument.Data.ManyValue[0].Attributes.Should().NotBeEmpty(); + responseDocument.Data.ManyValue[0].Relationships.Should().NotBeEmpty(); + } + + [Fact] + public async Task Can_get_primary_resources_with_redundant_scopes() + { + // Arrange + Actor actor = _fakers.Actor.GenerateOne(); + + 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.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("actors"); + responseDocument.Data.ManyValue[0].Id.Should().Be(actor.StringId); + responseDocument.Data.ManyValue[0].Attributes.Should().NotBeEmpty(); + responseDocument.Data.ManyValue[0].Relationships.Should().NotBeEmpty(); + } + + [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.Should().HaveCount(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.Should().NotBeNull(); + 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.Should().HaveCount(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.Should().NotBeNull(); + 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.Should().HaveCount(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.Should().NotBeNull(); + 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.Should().HaveCount(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.Should().NotBeNull(); + 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.Should().HaveCount(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.Should().NotBeNull(); + 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.Should().HaveCount(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.Should().NotBeNull(); + 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.Should().HaveCount(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.Should().NotBeNull(); + 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.Should().HaveCount(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.Should().NotBeNull(); + 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..ef1e7d5aca --- /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.GenerateOne(); + + 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.Should().HaveCount(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.Should().NotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_create_resource_with_relationships_without_scopes() + { + // Arrange + Movie newMovie = _fakers.Movie.GenerateOne(); + + 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.Should().HaveCount(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.Should().NotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_create_resource_with_relationships_with_read_scopes() + { + // Arrange + Movie newMovie = _fakers.Movie.GenerateOne(); + + 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.Should().HaveCount(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.Should().NotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_update_resource_without_scopes() + { + // Arrange + string newTitle = _fakers.Movie.GenerateOne().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.Should().HaveCount(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.Should().NotBeNull(); + error.Source.Header.Should().Be(ScopeHeaderName); + } + + [Fact] + public async Task Cannot_update_resource_with_relationships_without_scopes() + { + // Arrange + string newTitle = _fakers.Movie.GenerateOne().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.Should().HaveCount(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.Should().NotBeNull(); + 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.Should().HaveCount(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.Should().NotBeNull(); + 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.Should().HaveCount(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.Should().NotBeNull(); + 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.Should().HaveCount(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.Should().NotBeNull(); + 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.Should().HaveCount(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.Should().NotBeNull(); + 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.Should().HaveCount(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.Should().NotBeNull(); + 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..a4cba72878 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesAuthorizationFilter.cs @@ -0,0 +1,99 @@ +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(AuthScopeSet authScopeSet) : QueryExpressionRewriter + { + private readonly 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..296ded9b42 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesDbContext.cs @@ -0,0 +1,14 @@ +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Authorization.Scopes; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class ScopesDbContext(DbContextOptions options) + : TestableDbContext(options) +{ + public DbSet Movies => Set(); + public DbSet Actors => Set(); + public DbSet Genres => Set(); +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Authorization/Scopes/ScopesFakers.cs new file mode 100644 index 0000000000..ab3da93f38 --- /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 +{ + private readonly Lazy> _lazyMovieFaker = new(() => new Faker() + .MakeDeterministic() + .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() + .MakeDeterministic() + .RuleFor(actor => actor.Name, faker => faker.Person.FullName) + .RuleFor(actor => actor.BornAt, faker => faker.Date.Past())); + + private readonly Lazy> _lazyGenreFaker = new(() => new Faker() + .MakeDeterministic() + .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/BlobDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Blobs/BlobDbContext.cs index cadbb08b56..89601169e5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Blobs/BlobDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Blobs/BlobDbContext.cs @@ -1,15 +1,12 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; namespace JsonApiDotNetCoreTests.IntegrationTests.Blobs; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class BlobDbContext : DbContext +public sealed class BlobDbContext(DbContextOptions options) + : TestableDbContext(options) { public DbSet ImageContainers => Set(); - - public BlobDbContext(DbContextOptions options) - : base(options) - { - } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Blobs/BlobFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Blobs/BlobFakers.cs index 924b67dfa3..be84cf1ad1 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 +internal sealed class BlobFakers { - 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() + .MakeDeterministic() + .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/Blobs/BlobTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Blobs/BlobTests.cs index 4d21e284e8..3716c56214 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Blobs/BlobTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Blobs/BlobTests.cs @@ -19,17 +19,14 @@ public BlobTests(IntegrationTestContext, BlobDbCo testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); + testContext.ConfigureServices(services => services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>))); } [Fact] public async Task Can_get_primary_resource_by_ID() { // Arrange - ImageContainer container = _fakers.ImageContainer.Generate(); + ImageContainer container = _fakers.ImageContainer.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -45,12 +42,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Type.Should().Be("imageContainers"); responseDocument.Data.SingleValue.Id.Should().Be(container.StringId); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("fileName").With(value => value.Should().Be(container.FileName)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("data").As().With(value => value.Should().Equal(container.Data)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("thumbnail").As().With(value => value.Should().Equal(container.Thumbnail)); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("fileName").WhoseValue.Should().Be(container.FileName); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("data").WhoseValue.As().Should().Equal(container.Data); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("thumbnail").WhoseValue.As().Should().Equal(container.Thumbnail); responseDocument.Data.SingleValue.Relationships.Should().BeNull(); } @@ -58,7 +55,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_resource() { // Arrange - ImageContainer newContainer = _fakers.ImageContainer.Generate(); + ImageContainer newContainer = _fakers.ImageContainer.GenerateOne(); var requestBody = new { @@ -82,14 +79,14 @@ public async Task Can_create_resource() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Type.Should().Be("imageContainers"); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("fileName").With(value => value.Should().Be(newContainer.FileName)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("data").As().With(value => value.Should().Equal(newContainer.Data)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("thumbnail").As().With(value => value.Should().Equal(newContainer.Thumbnail)); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("fileName").WhoseValue.Should().Be(newContainer.FileName); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("data").WhoseValue.As().Should().Equal(newContainer.Data); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("thumbnail").WhoseValue.As().Should().Equal(newContainer.Thumbnail); responseDocument.Data.SingleValue.Relationships.Should().BeNull(); - long newContainerId = long.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + long newContainerId = long.Parse(responseDocument.Data.SingleValue.Id.Should().NotBeNull().And.Subject); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -105,10 +102,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_update_resource() { // Arrange - ImageContainer existingContainer = _fakers.ImageContainer.Generate(); + ImageContainer existingContainer = _fakers.ImageContainer.GenerateOne(); - byte[] newData = _fakers.ImageContainer.Generate().Data; - byte[] newThumbnail = _fakers.ImageContainer.Generate().Thumbnail!; + byte[] newData = _fakers.ImageContainer.GenerateOne().Data; + byte[] newThumbnail = _fakers.ImageContainer.GenerateOne().Thumbnail!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -138,12 +135,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Type.Should().Be("imageContainers"); responseDocument.Data.SingleValue.Id.Should().Be(existingContainer.StringId); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("fileName").With(value => value.Should().Be(existingContainer.FileName)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("data").As().With(value => value.Should().Equal(newData)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("thumbnail").As().With(value => value.Should().Equal(newThumbnail)); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("fileName").WhoseValue.Should().Be(existingContainer.FileName); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("data").WhoseValue.As().With(value => value.Should().Equal(newData)); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("thumbnail").WhoseValue.As().With(value => value.Should().Equal(newThumbnail)); responseDocument.Data.SingleValue.Relationships.Should().BeNull(); await _testContext.RunOnDatabaseAsync(async dbContext => @@ -160,7 +157,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_update_resource_with_empty_blob() { // Arrange - ImageContainer existingContainer = _fakers.ImageContainer.Generate(); + ImageContainer existingContainer = _fakers.ImageContainer.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -189,11 +186,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Type.Should().Be("imageContainers"); responseDocument.Data.SingleValue.Id.Should().Be(existingContainer.StringId); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("fileName").With(value => value.Should().Be(existingContainer.FileName)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("data").As().With(value => value.Should().BeEmpty()); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("fileName").WhoseValue.Should().Be(existingContainer.FileName); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("data").WhoseValue.As().With(value => value.Should().BeEmpty()); responseDocument.Data.SingleValue.Relationships.Should().BeNull(); await _testContext.RunOnDatabaseAsync(async dbContext => @@ -209,7 +206,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_update_resource_with_null_blob() { // Arrange - ImageContainer existingContainer = _fakers.ImageContainer.Generate(); + ImageContainer existingContainer = _fakers.ImageContainer.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -238,11 +235,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Type.Should().Be("imageContainers"); responseDocument.Data.SingleValue.Id.Should().Be(existingContainer.StringId); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("fileName").With(value => value.Should().Be(existingContainer.FileName)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("thumbnail").With(value => value.Should().BeNull()); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("fileName").WhoseValue.Should().Be(existingContainer.FileName); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("thumbnail").WhoseValue.Should().BeNull(); responseDocument.Data.SingleValue.Relationships.Should().BeNull(); await _testContext.RunOnDatabaseAsync(async dbContext => diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Blobs/ImageContainer.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Blobs/ImageContainer.cs index 4ad0e54d5a..3462cdb2df 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Blobs/ImageContainer.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Blobs/ImageContainer.cs @@ -12,7 +12,7 @@ public sealed class ImageContainer : Identifiable public string FileName { get; set; } = null!; [Attr] - public byte[] Data { get; set; } = Array.Empty(); + public byte[] Data { get; set; } = []; [Attr] public byte[]? Thumbnail { get; set; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Car.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Car.cs index 29213f5e69..f77626f13e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Car.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Car.cs @@ -12,13 +12,13 @@ public sealed class Car : Identifiable [NotMapped] public override string? Id { - get => RegionId == default && LicensePlate == default ? null : $"{RegionId}:{LicensePlate}"; + get => RegionId == 0 && LicensePlate == null ? null : $"{RegionId}:{LicensePlate}"; set { if (value == null) { - RegionId = default; - LicensePlate = default; + RegionId = 0; + LicensePlate = null; return; } @@ -47,4 +47,7 @@ public override string? Id [HasOne] public Dealership? Dealership { get; set; } + + [HasMany] + public ISet PreviousDealerships { get; set; } = new HashSet(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs index 989967bc10..d320c65d0f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarCompositeKeyAwareRepository.cs @@ -9,18 +9,14 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public class CarCompositeKeyAwareRepository : EntityFrameworkCoreRepository +public class CarCompositeKeyAwareRepository( + ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, + IEnumerable constraintProviders, ILoggerFactory loggerFactory, IResourceDefinitionAccessor resourceDefinitionAccessor) + : EntityFrameworkCoreRepository(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, + resourceDefinitionAccessor) where TResource : class, IIdentifiable { - private readonly CarExpressionRewriter _writer; - - public CarCompositeKeyAwareRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, - IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) - { - _writer = new CarExpressionRewriter(resourceGraph); - } + private readonly CarExpressionRewriter _writer = new(resourceGraph); protected override IQueryable ApplyQueryLayer(QueryLayer queryLayer) { @@ -43,12 +39,21 @@ private void RecursiveRewriteFilterInLayer(QueryLayer queryLayer) if (queryLayer.Selection is { IsEmpty: false }) { - foreach (QueryLayer? nextLayer in queryLayer.Selection.GetResourceTypes() - .Select(resourceType => queryLayer.Selection.GetOrCreateSelectors(resourceType)) - .SelectMany(selectors => selectors.Select(selector => selector.Value).Where(layer => layer != null))) + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true + + foreach (QueryLayer? nextLayer in queryLayer.Selection + .GetResourceTypes() + .Select(queryLayer.Selection.GetOrCreateSelectors) + .SelectMany(selectors => selectors + .Select(selector => selector.Value) + .Where(layer => layer != null))) { RecursiveRewriteFilterInLayer(nextLayer!); } + + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore } } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs index d5b421850e..e7802bcb09 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs @@ -1,6 +1,5 @@ using System.Collections.Immutable; using System.Reflection; -using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; @@ -30,7 +29,7 @@ public CarExpressionRewriter(IResourceGraph resourceGraph) public override QueryExpression? VisitComparison(ComparisonExpression expression, object? argument) { - if (expression.Left is ResourceFieldChainExpression leftChain && expression.Right is LiteralConstantExpression rightConstant) + if (expression is { Left: ResourceFieldChainExpression leftChain, Right: LiteralConstantExpression rightConstant }) { PropertyInfo leftProperty = leftChain.Fields[^1].Property; @@ -41,7 +40,8 @@ public CarExpressionRewriter(IResourceGraph resourceGraph) throw new NotSupportedException("Only equality comparisons are possible on Car IDs."); } - return RewriteFilterOnCarStringIds(leftChain, rightConstant.Value.AsEnumerable()); + string carStringId = (string)rightConstant.TypedValue; + return RewriteFilterOnCarStringIds(leftChain, [carStringId]); } } @@ -54,7 +54,7 @@ public CarExpressionRewriter(IResourceGraph resourceGraph) if (IsCarId(property)) { - string[] carStringIds = expression.Constants.Select(constant => constant.Value).ToArray(); + string[] carStringIds = expression.Constants.Select(constant => (string)constant.TypedValue).ToArray(); return RewriteFilterOnCarStringIds(expression.TargetAttribute, carStringIds); } @@ -89,18 +89,18 @@ private QueryExpression RewriteFilterOnCarStringIds(ResourceFieldChainExpression StringId = carStringId }; - FilterExpression keyComparison = CreateEqualityComparisonOnCompositeKey(existingCarIdChain, tempCar.RegionId, tempCar.LicensePlate!); + LogicalExpression keyComparison = CreateEqualityComparisonOnCompositeKey(existingCarIdChain, tempCar.RegionId, tempCar.LicensePlate!); outerTermsBuilder.Add(keyComparison); } return outerTermsBuilder.Count == 1 ? outerTermsBuilder[0] : new LogicalExpression(LogicalOperator.Or, outerTermsBuilder.ToImmutable()); } - private FilterExpression CreateEqualityComparisonOnCompositeKey(ResourceFieldChainExpression existingCarIdChain, long regionIdValue, + private LogicalExpression CreateEqualityComparisonOnCompositeKey(ResourceFieldChainExpression existingCarIdChain, long regionIdValue, string licensePlateValue) { ResourceFieldChainExpression regionIdChain = ReplaceLastAttributeInChain(existingCarIdChain, _regionIdAttribute); - var regionIdComparison = new ComparisonExpression(ComparisonOperator.Equals, regionIdChain, new LiteralConstantExpression(regionIdValue.ToString())); + var regionIdComparison = new ComparisonExpression(ComparisonOperator.Equals, regionIdChain, new LiteralConstantExpression(regionIdValue)); ResourceFieldChainExpression licensePlateChain = ReplaceLastAttributeInChain(existingCarIdChain, _licensePlateAttribute); var licensePlateComparison = new ComparisonExpression(ComparisonOperator.Equals, licensePlateChain, new LiteralConstantExpression(licensePlateValue)); @@ -116,10 +116,12 @@ public override QueryExpression VisitSort(SortExpression expression, object? arg { if (IsSortOnCarId(sortElement)) { - ResourceFieldChainExpression regionIdSort = ReplaceLastAttributeInChain(sortElement.TargetAttribute!, _regionIdAttribute); + var fieldChain = (ResourceFieldChainExpression)sortElement.Target; + + ResourceFieldChainExpression regionIdSort = ReplaceLastAttributeInChain(fieldChain, _regionIdAttribute); elementsBuilder.Add(new SortElementExpression(regionIdSort, sortElement.IsAscending)); - ResourceFieldChainExpression licensePlateSort = ReplaceLastAttributeInChain(sortElement.TargetAttribute!, _licensePlateAttribute); + ResourceFieldChainExpression licensePlateSort = ReplaceLastAttributeInChain(fieldChain, _licensePlateAttribute); elementsBuilder.Add(new SortElementExpression(licensePlateSort, sortElement.IsAscending)); } else @@ -133,9 +135,9 @@ public override QueryExpression VisitSort(SortExpression expression, object? arg private static bool IsSortOnCarId(SortElementExpression sortElement) { - if (sortElement.TargetAttribute != null) + if (sortElement.Target is ResourceFieldChainExpression fieldChain && fieldChain.Fields[^1] is AttrAttribute attribute) { - PropertyInfo property = sortElement.TargetAttribute.Fields[^1].Property; + PropertyInfo property = attribute.Property; if (IsCarId(property)) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs index 67213057b7..c4f8e0f26a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs @@ -1,22 +1,19 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; // @formatter:wrap_chained_method_calls chop_always namespace JsonApiDotNetCoreTests.IntegrationTests.CompositeKeys; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class CompositeDbContext : DbContext +public sealed class CompositeDbContext(DbContextOptions options) + : TestableDbContext(options) { public DbSet Cars => Set(); public DbSet Engines => Set(); public DbSet Dealerships => Set(); - public CompositeDbContext(DbContextOptions options) - : base(options) - { - } - protected override void OnModelCreating(ModelBuilder builder) { builder.Entity() @@ -34,5 +31,11 @@ protected override void OnModelCreating(ModelBuilder builder) builder.Entity() .HasMany(dealership => dealership.Inventory) .WithOne(car => car.Dealership!); + + builder.Entity() + .HasMany(car => car.PreviousDealerships) + .WithMany(dealership => dealership.SoldCars); + + base.OnModelCreating(builder); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/CompositeKeyFakers.cs index ce3a4455d1..56cc55beee 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 +internal sealed class CompositeKeyFakers { - 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() + .MakeDeterministic() + .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() + .MakeDeterministic() + .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() + .MakeDeterministic() + .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 185367930d..3ea92e7c17 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; @@ -22,21 +21,18 @@ public CompositeKeyTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceRepository>(); services.AddResourceRepository>(); }); - - var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); - options.AllowClientGeneratedIds = true; } [Fact] public async Task Can_filter_on_ID_in_primary_resources() { // Arrange - Car car = _fakers.Car.Generate(); + Car car = _fakers.Car.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -53,7 +49,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(car.StringId); } @@ -61,7 +57,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_get_primary_resource_by_ID() { // Arrange - Car car = _fakers.Car.Generate(); + Car car = _fakers.Car.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -78,7 +74,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(car.StringId); } @@ -86,7 +82,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_sort_on_ID() { // Arrange - Car car = _fakers.Car.Generate(); + Car car = _fakers.Car.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -103,7 +99,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(car.StringId); } @@ -111,7 +107,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_select_ID() { // Arrange - Car car = _fakers.Car.Generate(); + Car car = _fakers.Car.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -128,7 +124,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(car.StringId); } @@ -136,9 +132,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_resource() { // Arrange - Engine existingEngine = _fakers.Engine.Generate(); + Engine existingEngine = _fakers.Engine.GenerateOne(); - Car newCar = _fakers.Car.Generate(); + Car newCar = _fakers.Car.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -185,7 +181,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Car? carInDatabase = await dbContext.Cars.FirstOrDefaultAsync(car => car.RegionId == newCar.RegionId && car.LicensePlate == newCar.LicensePlate); - carInDatabase.ShouldNotBeNull(); + carInDatabase.Should().NotBeNull(); carInDatabase.Id.Should().Be($"{newCar.RegionId}:{newCar.LicensePlate}"); }); } @@ -194,8 +190,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_OneToOne_relationship() { // Arrange - Car existingCar = _fakers.Car.Generate(); - Engine existingEngine = _fakers.Engine.Generate(); + Car existingCar = _fakers.Car.GenerateOne(); + Engine existingEngine = _fakers.Engine.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -238,7 +234,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Engine engineInDatabase = await dbContext.Engines.Include(engine => engine.Car).FirstWithIdAsync(existingEngine.Id); - engineInDatabase.Car.ShouldNotBeNull(); + engineInDatabase.Car.Should().NotBeNull(); engineInDatabase.Car.Id.Should().Be(existingCar.StringId); }); } @@ -247,8 +243,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_clear_OneToOne_relationship() { // Arrange - Engine existingEngine = _fakers.Engine.Generate(); - existingEngine.Car = _fakers.Car.Generate(); + Engine existingEngine = _fakers.Engine.GenerateOne(); + existingEngine.Car = _fakers.Car.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -295,8 +291,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_remove_from_OneToMany_relationship() { // Arrange - Dealership existingDealership = _fakers.Dealership.Generate(); - existingDealership.Inventory = _fakers.Car.Generate(2).ToHashSet(); + Dealership existingDealership = _fakers.Dealership.GenerateOne(); + existingDealership.Inventory = _fakers.Car.GenerateSet(2); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -331,7 +327,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Dealership dealershipInDatabase = await dbContext.Dealerships.Include(dealership => dealership.Inventory).FirstWithIdAsync(existingDealership.Id); - dealershipInDatabase.Inventory.ShouldHaveCount(1); + dealershipInDatabase.Inventory.Should().HaveCount(1); dealershipInDatabase.Inventory.Should().ContainSingle(car => car.Id == existingDealership.Inventory.ElementAt(1).Id); }); } @@ -340,8 +336,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_add_to_OneToMany_relationship() { // Arrange - Dealership existingDealership = _fakers.Dealership.Generate(); - Car existingCar = _fakers.Car.Generate(); + Dealership existingDealership = _fakers.Dealership.GenerateOne(); + Car existingCar = _fakers.Car.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -376,7 +372,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Dealership dealershipInDatabase = await dbContext.Dealerships.Include(dealership => dealership.Inventory).FirstWithIdAsync(existingDealership.Id); - dealershipInDatabase.Inventory.ShouldHaveCount(1); + dealershipInDatabase.Inventory.Should().HaveCount(1); dealershipInDatabase.Inventory.Should().ContainSingle(car => car.Id == existingCar.Id); }); } @@ -385,10 +381,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_replace_OneToMany_relationship() { // Arrange - Dealership existingDealership = _fakers.Dealership.Generate(); - existingDealership.Inventory = _fakers.Car.Generate(2).ToHashSet(); + Dealership existingDealership = _fakers.Dealership.GenerateOne(); + existingDealership.Inventory = _fakers.Car.GenerateSet(2); - Car existingCar = _fakers.Car.Generate(); + Car existingCar = _fakers.Car.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -428,7 +424,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { Dealership dealershipInDatabase = await dbContext.Dealerships.Include(dealership => dealership.Inventory).FirstWithIdAsync(existingDealership.Id); - dealershipInDatabase.Inventory.ShouldHaveCount(2); + dealershipInDatabase.Inventory.Should().HaveCount(2); dealershipInDatabase.Inventory.Should().ContainSingle(car => car.Id == existingCar.Id); dealershipInDatabase.Inventory.Should().ContainSingle(car => car.Id == existingDealership.Inventory.ElementAt(0).Id); }); @@ -438,9 +434,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_remove_from_ManyToOne_relationship_for_unknown_relationship_ID() { // Arrange - Dealership existingDealership = _fakers.Dealership.Generate(); + Dealership existingDealership = _fakers.Dealership.GenerateOne(); - string unknownCarId = _fakers.Car.Generate().StringId!; + string unknownCarId = _fakers.Car.GenerateOne().StringId!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -469,7 +465,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -481,7 +477,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_delete_resource() { // Arrange - Car existingCar = _fakers.Car.Generate(); + Car existingCar = _fakers.Car.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -508,4 +504,52 @@ await _testContext.RunOnDatabaseAsync(async dbContext => carInDatabase.Should().BeNull(); }); } + + [Fact] + public async Task Can_remove_from_ManyToMany_relationship() + { + // Arrange + Dealership existingDealership = _fakers.Dealership.GenerateOne(); + existingDealership.SoldCars = _fakers.Car.GenerateSet(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Dealerships.Add(existingDealership); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "cars", + id = existingDealership.SoldCars.ElementAt(1).StringId + } + } + }; + + string route = $"/dealerships/{existingDealership.StringId}/relationships/soldCars"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Dealership dealershipInDatabase = await dbContext.Dealerships.Include(dealership => dealership.SoldCars).FirstWithIdAsync(existingDealership.Id); + + dealershipInDatabase.SoldCars.Should().HaveCount(1); + dealershipInDatabase.SoldCars.Single().Id.Should().Be(existingDealership.SoldCars.ElementAt(0).Id); + + List carsInDatabase = await dbContext.Cars.ToListAsync(); + carsInDatabase.Should().HaveCount(2); + }); + } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Dealership.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Dealership.cs index 14784cb438..091e7acbe1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Dealership.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CompositeKeys/Dealership.cs @@ -13,4 +13,7 @@ public sealed class Dealership : Identifiable [HasMany] public ISet Inventory { get; set; } = new HashSet(); + + [HasMany] + public ISet SoldCars { get; set; } = new HashSet(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs index 20fc6f8032..d7b1616501 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs @@ -21,61 +21,37 @@ public AcceptHeaderTests(IntegrationTestContext } [Fact] - public async Task Permits_no_Accept_headers() + public async Task Permits_global_wildcard_in_Accept_headers() { // Arrange const string route = "/policies"; - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - } - - [Fact] - public async Task Permits_no_Accept_headers_at_operations_endpoint() - { - // Arrange - var requestBody = new + Action setRequestHeaders = headers => { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "policies", - attributes = new - { - name = "some" - } - } - } - } + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("*/*")); }; - const string route = "/operations"; - const string contentType = HeaderConstants.AtomicOperationsMediaType; - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType); + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); } [Fact] - public async Task Permits_global_wildcard_in_Accept_headers() + public async Task Permits_application_wildcard_in_Accept_headers() { // Arrange const string route = "/policies"; Action setRequestHeaders = headers => { - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("*/*")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html;q=0.8")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/*;q=0.2")); }; // Act @@ -83,18 +59,24 @@ public async Task Permits_global_wildcard_in_Accept_headers() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); } [Fact] - public async Task Permits_application_wildcard_in_Accept_headers() + public async Task Permits_JsonApi_without_parameters_in_Accept_headers() { // Arrange const string route = "/policies"; Action setRequestHeaders = headers => { - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html;q=0.8")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/*;q=0.2")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default}; profile=some")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default}; ext=other")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default}; unknown=unexpected")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default}; q=0.3")); }; // Act @@ -102,32 +84,59 @@ public async Task Permits_application_wildcard_in_Accept_headers() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); } [Fact] - public async Task Permits_JsonApi_without_parameters_in_Accept_headers() + public async Task Prefers_JsonApi_with_AtomicOperations_extension_in_Accept_headers_at_operations_endpoint() { // Arrange - const string route = "/policies"; + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + } + } + }; + + const string route = "/operations"; + string contentType = JsonApiMediaType.AtomicOperations.ToString(); Action setRequestHeaders = headers => { headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; profile=some")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; ext=other")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; unknown=unexpected")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; q=0.3")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default}; profile=some")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(JsonApiMediaType.Default.ToString())); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default}; unknown=unexpected")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default};EXT=atomic; q=0.8")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default};EXT=\"https://jsonapi.org/ext/atomic\"; q=0.2")); }; // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType, setRequestHeaders); // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.AtomicOperations.ToString()); } [Fact] - public async Task Permits_JsonApi_with_AtomicOperations_extension_in_Accept_headers_at_operations_endpoint() + public async Task Prefers_JsonApi_with_relaxed_AtomicOperations_extension_in_Accept_headers_at_operations_endpoint() { // Arrange var requestBody = new @@ -150,15 +159,16 @@ public async Task Permits_JsonApi_with_AtomicOperations_extension_in_Accept_head }; const string route = "/operations"; - const string contentType = HeaderConstants.AtomicOperationsMediaType; + string contentType = JsonApiMediaType.RelaxedAtomicOperations.ToString(); Action setRequestHeaders = headers => { headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; profile=some")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType)); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; unknown=unexpected")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType};ext=\"https://jsonapi.org/ext/atomic\"; q=0.2")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default}; profile=some")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(JsonApiMediaType.Default.ToString())); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default}; unknown=unexpected")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default};EXT=\"https://jsonapi.org/ext/atomic\"; q=0.8")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default};EXT=atomic; q=0.2")); }; // Act @@ -166,6 +176,9 @@ public async Task Permits_JsonApi_with_AtomicOperations_extension_in_Accept_head // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.RelaxedAtomicOperations.ToString()); } [Fact] @@ -177,10 +190,10 @@ public async Task Denies_JsonApi_with_parameters_in_Accept_headers() Action setRequestHeaders = headers => { headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; profile=some")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; ext=other")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{HeaderConstants.MediaType}; unknown=unexpected")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.AtomicOperationsMediaType)); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default}; profile=some")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default}; ext=other")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default}; unknown=unexpected")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(JsonApiMediaType.AtomicOperations.ToString())); }; // Act @@ -189,18 +202,18 @@ public async Task Denies_JsonApi_with_parameters_in_Accept_headers() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotAcceptable); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotAcceptable); error.Title.Should().Be("The specified Accept header value does not contain any supported media types."); - error.Detail.Should().Be("Please include 'application/vnd.api+json' in the Accept header values."); - error.Source.ShouldNotBeNull(); + error.Detail.Should().Be($"Include '{JsonApiMediaType.Default}' in the Accept header values."); + error.Source.Should().NotBeNull(); error.Source.Header.Should().Be("Accept"); } [Fact] - public async Task Denies_JsonApi_in_Accept_headers_at_operations_endpoint() + public async Task Denies_no_Accept_headers_at_operations_endpoint() { // Arrange var requestBody = new @@ -223,13 +236,58 @@ public async Task Denies_JsonApi_in_Accept_headers_at_operations_endpoint() }; const string route = "/operations"; - const string contentType = HeaderConstants.AtomicOperationsMediaType; + string contentType = JsonApiMediaType.AtomicOperations.ToString(); - Action setRequestHeaders = headers => + Action requestHeaders = _ => { - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType)); }; + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = + await _testContext.ExecutePostAsync(route, requestBody, contentType, requestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotAcceptable); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotAcceptable); + error.Title.Should().Be("The specified Accept header value does not contain any supported media types."); + error.Detail.Should().Be($"Include '{JsonApiMediaType.AtomicOperations}' or '{JsonApiMediaType.RelaxedAtomicOperations}' in the Accept header values."); + error.Source.Should().NotBeNull(); + error.Source.Header.Should().Be("Accept"); + } + + [Fact] + public async Task Denies_JsonApi_in_Accept_headers_at_operations_endpoint() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + } + } + }; + + const string route = "/operations"; + string contentType = JsonApiMediaType.AtomicOperations.ToString(); + + Action setRequestHeaders = headers => + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(JsonApiMediaType.Default.ToString())); + // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType, setRequestHeaders); @@ -237,13 +295,13 @@ public async Task Denies_JsonApi_in_Accept_headers_at_operations_endpoint() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotAcceptable); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotAcceptable); error.Title.Should().Be("The specified Accept header value does not contain any supported media types."); - error.Detail.Should().Be("Please include 'application/vnd.api+json; ext=\"https://jsonapi.org/ext/atomic\"' in the Accept header values."); - error.Source.ShouldNotBeNull(); + error.Detail.Should().Be($"Include '{JsonApiMediaType.AtomicOperations}' or '{JsonApiMediaType.RelaxedAtomicOperations}' in the Accept header values."); + error.Source.Should().NotBeNull(); error.Source.Header.Should().Be("Accept"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs index 793dee05c8..5627265ea6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Net.Http.Headers; using FluentAssertions; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Serialization.Objects; @@ -30,8 +31,9 @@ public async Task Returns_JsonApi_ContentType_header() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); - httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.MediaType); + + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); } [Fact] @@ -64,8 +66,48 @@ public async Task Returns_JsonApi_ContentType_header_with_AtomicOperations_exten // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); - httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.AtomicOperationsMediaType); + + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.AtomicOperations.ToString()); + } + + [Fact] + public async Task Returns_JsonApi_ContentType_header_with_relaxed_AtomicOperations_extension() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + } + } + }; + + const string route = "/operations"; + string contentType = JsonApiMediaType.RelaxedAtomicOperations.ToString(); + + Action setRequestHeaders = headers => + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(JsonApiMediaType.RelaxedAtomicOperations.ToString())); + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.RelaxedAtomicOperations.ToString()); } [Fact] @@ -93,13 +135,64 @@ public async Task Denies_unknown_ContentType_header() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnsupportedMediaType); - responseDocument.Errors.ShouldHaveCount(1); + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); + + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); error.Title.Should().Be("The specified Content-Type header value is not supported."); - error.Detail.Should().Be("Please specify 'application/vnd.api+json' instead of 'text/html' for the Content-Type header value."); - error.Source.ShouldNotBeNull(); + error.Detail.Should().Be($"Use '{JsonApiMediaType.Default}' instead of 'text/html' for the Content-Type header value."); + error.Source.Should().NotBeNull(); + error.Source.Header.Should().Be("Content-Type"); + } + + [Fact] + public async Task Denies_unknown_ContentType_header_at_operations_endpoint() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + } + } + }; + + const string route = "/operations"; + const string contentType = "text/html"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnsupportedMediaType); + + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); + + responseDocument.Errors.Should().HaveCount(1); + + string detail = + $"Use '{JsonApiMediaType.AtomicOperations}' or '{JsonApiMediaType.RelaxedAtomicOperations}' instead of 'text/html' for the Content-Type header value."; + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); + error.Title.Should().Be("The specified Content-Type header value is not supported."); + error.Detail.Should().Be(detail); + error.Source.Should().NotBeNull(); error.Source.Header.Should().Be("Content-Type"); } @@ -120,14 +213,45 @@ public async Task Permits_JsonApi_ContentType_header() }; const string route = "/policies"; - const string contentType = HeaderConstants.MediaType; + string contentType = JsonApiMediaType.Default.ToString(); + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); + } + + [Fact] + public async Task Permits_JsonApi_ContentType_header_in_upper_case() + { + // Arrange + var requestBody = new + { + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + }; + + const string route = "/policies"; + string contentType = JsonApiMediaType.Default.ToString().ToUpperInvariant(); // Act - // ReSharper disable once RedundantArgumentDefaultValue (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType); // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); } [Fact] @@ -154,17 +278,106 @@ public async Task Permits_JsonApi_ContentType_header_with_AtomicOperations_exten }; const string route = "/operations"; - const string contentType = HeaderConstants.AtomicOperationsMediaType; // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType); + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody); // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.AtomicOperations.ToString()); } [Fact] - public async Task Denies_JsonApi_ContentType_header_with_profile() + public async Task Denies_JsonApi_ContentType_header_with_AtomicOperations_extension_at_operations_endpoint_in_upper_case() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + } + } + }; + + const string route = "/operations"; + string contentType = JsonApiMediaType.AtomicOperations.ToString().ToUpperInvariant(); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnsupportedMediaType); + + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); + + responseDocument.Errors.Should().HaveCount(1); + + string detail = + $"Use '{JsonApiMediaType.AtomicOperations}' or '{JsonApiMediaType.RelaxedAtomicOperations}' instead of '{contentType}' for the Content-Type header value."; + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); + error.Title.Should().Be("The specified Content-Type header value is not supported."); + error.Detail.Should().Be(detail); + error.Source.Should().NotBeNull(); + error.Source.Header.Should().Be("Content-Type"); + } + + [Fact] + public async Task Permits_JsonApi_ContentType_header_with_relaxed_AtomicOperations_extension_at_operations_endpoint() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + } + } + }; + + const string route = "/operations"; + string contentType = JsonApiMediaType.RelaxedAtomicOperations.ToString(); + + Action setRequestHeaders = headers => + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(JsonApiMediaType.RelaxedAtomicOperations.ToString())); + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.RelaxedAtomicOperations.ToString()); + } + + [Fact] + public async Task Denies_JsonApi_ContentType_header_with_unknown_extension() { // Arrange var requestBody = new @@ -180,7 +393,7 @@ public async Task Denies_JsonApi_ContentType_header_with_profile() }; const string route = "/policies"; - const string contentType = $"{HeaderConstants.MediaType}; profile=something"; + string contentType = $"{JsonApiMediaType.Default}; ext=something"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); @@ -188,18 +401,21 @@ public async Task Denies_JsonApi_ContentType_header_with_profile() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnsupportedMediaType); - responseDocument.Errors.ShouldHaveCount(1); + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); + + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); error.Title.Should().Be("The specified Content-Type header value is not supported."); - error.Detail.Should().Be($"Please specify 'application/vnd.api+json' instead of '{contentType}' for the Content-Type header value."); - error.Source.ShouldNotBeNull(); + error.Detail.Should().Be($"Use '{JsonApiMediaType.Default}' instead of '{contentType}' for the Content-Type header value."); + error.Source.Should().NotBeNull(); error.Source.Header.Should().Be("Content-Type"); } [Fact] - public async Task Denies_JsonApi_ContentType_header_with_extension() + public async Task Denies_JsonApi_ContentType_header_with_AtomicOperations_extension_at_resource_endpoint() { // Arrange var requestBody = new @@ -215,7 +431,7 @@ public async Task Denies_JsonApi_ContentType_header_with_extension() }; const string route = "/policies"; - const string contentType = $"{HeaderConstants.MediaType}; ext=something"; + string contentType = JsonApiMediaType.AtomicOperations.ToString(); // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); @@ -223,18 +439,59 @@ public async Task Denies_JsonApi_ContentType_header_with_extension() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnsupportedMediaType); - responseDocument.Errors.ShouldHaveCount(1); + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); + + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); error.Title.Should().Be("The specified Content-Type header value is not supported."); - error.Detail.Should().Be($"Please specify 'application/vnd.api+json' instead of '{contentType}' for the Content-Type header value."); - error.Source.ShouldNotBeNull(); + error.Detail.Should().Be($"Use '{JsonApiMediaType.Default}' instead of '{contentType}' for the Content-Type header value."); + error.Source.Should().NotBeNull(); error.Source.Header.Should().Be("Content-Type"); } [Fact] - public async Task Denies_JsonApi_ContentType_header_with_AtomicOperations_extension_at_resource_endpoint() + public async Task Denies_JsonApi_ContentType_header_with_relaxed_AtomicOperations_extension_at_resource_endpoint() + { + // Arrange + var requestBody = new + { + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + }; + + const string route = "/policies"; + string contentType = JsonApiMediaType.RelaxedAtomicOperations.ToString(); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnsupportedMediaType); + + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); + error.Title.Should().Be("The specified Content-Type header value is not supported."); + error.Detail.Should().Be($"Use '{JsonApiMediaType.Default}' instead of '{contentType}' for the Content-Type header value."); + error.Source.Should().NotBeNull(); + error.Source.Header.Should().Be("Content-Type"); + } + + [Fact] + public async Task Denies_JsonApi_ContentType_header_with_profile() { // Arrange var requestBody = new @@ -250,7 +507,7 @@ public async Task Denies_JsonApi_ContentType_header_with_AtomicOperations_extens }; const string route = "/policies"; - const string contentType = HeaderConstants.AtomicOperationsMediaType; + string contentType = $"{JsonApiMediaType.Default}; profile=something"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); @@ -258,13 +515,16 @@ public async Task Denies_JsonApi_ContentType_header_with_AtomicOperations_extens // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnsupportedMediaType); - responseDocument.Errors.ShouldHaveCount(1); + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); + + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); error.Title.Should().Be("The specified Content-Type header value is not supported."); - error.Detail.Should().Be($"Please specify 'application/vnd.api+json' instead of '{contentType}' for the Content-Type header value."); - error.Source.ShouldNotBeNull(); + error.Detail.Should().Be($"Use '{JsonApiMediaType.Default}' instead of '{contentType}' for the Content-Type header value."); + error.Source.Should().NotBeNull(); error.Source.Header.Should().Be("Content-Type"); } @@ -285,7 +545,7 @@ public async Task Denies_JsonApi_ContentType_header_with_CharSet() }; const string route = "/policies"; - const string contentType = $"{HeaderConstants.MediaType}; charset=ISO-8859-4"; + string contentType = $"{JsonApiMediaType.Default}; charset=ISO-8859-4"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); @@ -293,13 +553,16 @@ public async Task Denies_JsonApi_ContentType_header_with_CharSet() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnsupportedMediaType); - responseDocument.Errors.ShouldHaveCount(1); + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); + + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); error.Title.Should().Be("The specified Content-Type header value is not supported."); - error.Detail.Should().Be($"Please specify 'application/vnd.api+json' instead of '{contentType}' for the Content-Type header value."); - error.Source.ShouldNotBeNull(); + error.Detail.Should().Be($"Use '{JsonApiMediaType.Default}' instead of '{contentType}' for the Content-Type header value."); + error.Source.Should().NotBeNull(); error.Source.Header.Should().Be("Content-Type"); } @@ -320,7 +583,7 @@ public async Task Denies_JsonApi_ContentType_header_with_unknown_parameter() }; const string route = "/policies"; - const string contentType = $"{HeaderConstants.MediaType}; unknown=unexpected"; + string contentType = $"{JsonApiMediaType.Default}; unknown=unexpected"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); @@ -328,13 +591,16 @@ public async Task Denies_JsonApi_ContentType_header_with_unknown_parameter() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnsupportedMediaType); - responseDocument.Errors.ShouldHaveCount(1); + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); + + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); error.Title.Should().Be("The specified Content-Type header value is not supported."); - error.Detail.Should().Be($"Please specify 'application/vnd.api+json' instead of '{contentType}' for the Content-Type header value."); - error.Source.ShouldNotBeNull(); + error.Detail.Should().Be($"Use '{JsonApiMediaType.Default}' instead of '{contentType}' for the Content-Type header value."); + error.Source.Should().NotBeNull(); error.Source.Header.Should().Be("Content-Type"); } @@ -362,24 +628,27 @@ public async Task Denies_JsonApi_ContentType_header_at_operations_endpoint() }; const string route = "/operations"; - const string contentType = HeaderConstants.MediaType; + string contentType = JsonApiMediaType.Default.ToString(); // Act - // ReSharper disable once RedundantArgumentDefaultValue (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnsupportedMediaType); - responseDocument.Errors.ShouldHaveCount(1); + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); + + responseDocument.Errors.Should().HaveCount(1); - const string detail = $"Please specify '{HeaderConstants.AtomicOperationsMediaType}' instead of '{contentType}' for the Content-Type header value."; + string detail = + $"Use '{JsonApiMediaType.AtomicOperations}' or '{JsonApiMediaType.RelaxedAtomicOperations}' instead of '{contentType}' for the Content-Type header value."; ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); error.Title.Should().Be("The specified Content-Type header value is not supported."); error.Detail.Should().Be(detail); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Header.Should().Be("Content-Type"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/CapturingDocumentAdapter.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/CapturingDocumentAdapter.cs new file mode 100644 index 0000000000..fc138415b3 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/CapturingDocumentAdapter.cs @@ -0,0 +1,25 @@ +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Serialization.Request.Adapters; + +namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation.CustomExtensions; + +internal sealed class CapturingDocumentAdapter : IDocumentAdapter +{ + private readonly IDocumentAdapter _innerAdapter; + private readonly RequestDocumentStore _requestDocumentStore; + + public CapturingDocumentAdapter(IDocumentAdapter innerAdapter, RequestDocumentStore requestDocumentStore) + { + ArgumentNullException.ThrowIfNull(innerAdapter); + ArgumentNullException.ThrowIfNull(requestDocumentStore); + + _innerAdapter = innerAdapter; + _requestDocumentStore = requestDocumentStore; + } + + public object? Convert(Document document) + { + _requestDocumentStore.Document = document; + return _innerAdapter.Convert(document); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/CustomExtensionsAcceptHeaderTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/CustomExtensionsAcceptHeaderTests.cs new file mode 100644 index 0000000000..d1c859aaea --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/CustomExtensionsAcceptHeaderTests.cs @@ -0,0 +1,156 @@ +using System.Net; +using System.Net.Http.Headers; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Serialization.Response; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation.CustomExtensions; + +public sealed class CustomExtensionsAcceptHeaderTests : IClassFixture, PolicyDbContext>> +{ + private readonly IntegrationTestContext, PolicyDbContext> _testContext; + + public CustomExtensionsAcceptHeaderTests(IntegrationTestContext, PolicyDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + + testContext.ConfigureServices(services => + { + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + }); + + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.IncludeExtensions(ServerTimeMediaTypeExtension.ServerTime, ServerTimeMediaTypeExtension.RelaxedServerTime); + } + + [Fact] + public async Task Permits_JsonApi_without_parameters_in_Accept_headers() + { + // Arrange + const string route = "/policies"; + + Action setRequestHeaders = headers => + { + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default}; profile=some")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default}; ext=other")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default}; unknown=unexpected")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default}; q=0.3")); + }; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); + } + + [Fact] + public async Task Prefers_first_match_from_GetPossibleMediaTypes_with_largest_number_of_extensions() + { + // Arrange + const string route = "/policies"; + + Action setRequestHeaders = headers => + { + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(JsonApiMediaType.Default.ToString())); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(ServerTimeMediaTypes.RelaxedServerTime.ToString())); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(ServerTimeMediaTypes.ServerTime.ToString())); + }; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(ServerTimeMediaTypes.ServerTime.ToString()); + } + + [Fact] + public async Task Prefers_quality_factor_over_largest_number_of_extensions() + { + // Arrange + const string route = "/policies"; + + Action setRequestHeaders = headers => + { + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{ServerTimeMediaTypes.ServerTime}; q=0.2")); + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse($"{JsonApiMediaType.Default}; q=0.8")); + }; + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); + } + + [Fact] + public async Task Denies_extensions_mismatch_between_ContentType_and_Accept_header() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + } + } + }; + + const string route = "/operations"; + string contentType = ServerTimeMediaTypes.AtomicOperationsWithServerTime.ToString(); + + Action setRequestHeaders = headers => + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(JsonApiMediaType.AtomicOperations.ToString())); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = + await _testContext.ExecutePostAsync(route, requestBody, contentType, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotAcceptable); + + responseDocument.Errors.Should().HaveCount(1); + + string detail = $"Include '{JsonApiMediaType.AtomicOperations}' or '{ServerTimeMediaTypes.AtomicOperationsWithServerTime}' or " + + $"'{JsonApiMediaType.RelaxedAtomicOperations}' or '{ServerTimeMediaTypes.RelaxedAtomicOperationsWithRelaxedServerTime}' in the Accept header values."; + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.NotAcceptable); + error.Title.Should().Be("The specified Accept header value does not contain any supported media types."); + error.Detail.Should().Be(detail); + error.Source.Should().NotBeNull(); + error.Source.Header.Should().Be("Accept"); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/CustomExtensionsContentTypeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/CustomExtensionsContentTypeTests.cs new file mode 100644 index 0000000000..261f7b5149 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/CustomExtensionsContentTypeTests.cs @@ -0,0 +1,336 @@ +using System.Net; +using System.Net.Http.Headers; +using FluentAssertions; +using FluentAssertions.Extensions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Serialization.Request.Adapters; +using JsonApiDotNetCore.Serialization.Response; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation.CustomExtensions; + +public sealed class CustomExtensionsContentTypeTests : IClassFixture, PolicyDbContext>> +{ + private static readonly DateTimeOffset CurrentTime = 31.December(2024).At(21, 53, 40).AsUtc(); + private readonly IntegrationTestContext, PolicyDbContext> _testContext; + + public CustomExtensionsContentTypeTests(IntegrationTestContext, PolicyDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + + testContext.ConfigureServices(services => + { + services.AddSingleton(); + services.AddScoped(); + + services.AddScoped(); + services.AddScoped(); + + services.AddScoped(serviceProvider => + { + var documentAdapter = serviceProvider.GetRequiredService(); + var requestDocumentStore = serviceProvider.GetRequiredService(); + return new CapturingDocumentAdapter(documentAdapter, requestDocumentStore); + }); + }); + + testContext.PostConfigureServices(services => services.Replace( + ServiceDescriptor.Singleton(new FrozenTimeProvider(CurrentTime, TimeZoneInfo.FindSystemTimeZoneById("Tokyo Standard Time"))))); + + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.IncludeExtensions(ServerTimeMediaTypeExtension.ServerTime, ServerTimeMediaTypeExtension.RelaxedServerTime); + } + + [Fact] + public async Task Permits_JsonApi_ContentType_header() + { + // Arrange + var requestBody = new + { + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + }; + + const string route = "/policies"; + string contentType = JsonApiMediaType.Default.ToString(); + + // Act + (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); + } + + [Fact] + public async Task Permits_JsonApi_ContentType_header_with_ServerTime_extension() + { + // Arrange + var requestBody = new + { + meta = new + { + useLocalTime = true + }, + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + }; + + const string route = "/policies"; + string contentType = ServerTimeMediaTypes.ServerTime.ToString(); + + Action setRequestHeaders = headers => + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(ServerTimeMediaTypes.ServerTime.ToString())); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = + await _testContext.ExecutePostAsync(route, requestBody, contentType, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(ServerTimeMediaTypes.ServerTime.ToString()); + + responseDocument.Meta.Should().ContainKey("localServerTime").WhoseValue.Should().NotBeNull().And.Subject.ToString().Should() + .Be("2025-01-01T06:53:40.0000000+09:00"); + } + + [Fact] + public async Task Permits_JsonApi_ContentType_header_with_relaxed_ServerTime_extension() + { + // Arrange + var requestBody = new + { + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + }; + + const string route = "/policies"; + string contentType = ServerTimeMediaTypes.RelaxedServerTime.ToString(); + + Action setRequestHeaders = headers => + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(ServerTimeMediaTypes.RelaxedServerTime.ToString())); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = + await _testContext.ExecutePostAsync(route, requestBody, contentType, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(ServerTimeMediaTypes.RelaxedServerTime.ToString()); + + responseDocument.Meta.Should().ContainKey("utcServerTime").WhoseValue.Should().NotBeNull().And.Subject.ToString().Should() + .Be("2024-12-31T21:53:40.0000000Z"); + } + + [Fact] + public async Task Permits_JsonApi_ContentType_header_with_AtomicOperations_and_ServerTime_extension_at_operations_endpoint() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + } + } + }; + + const string route = "/operations"; + string contentType = ServerTimeMediaTypes.AtomicOperationsWithServerTime.ToString(); + + Action setRequestHeaders = headers => + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(ServerTimeMediaTypes.AtomicOperationsWithServerTime.ToString())); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = + await _testContext.ExecutePostAsync(route, requestBody, contentType, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(ServerTimeMediaTypes.AtomicOperationsWithServerTime.ToString()); + + responseDocument.Meta.Should().ContainKey("utcServerTime").WhoseValue.Should().NotBeNull().And.Subject.ToString().Should() + .Be("2024-12-31T21:53:40.0000000Z"); + } + + [Fact] + public async Task Permits_JsonApi_ContentType_header_with_relaxed_AtomicOperations_and_relaxed_ServerTime_extension_at_operations_endpoint() + { + // Arrange + var requestBody = new + { + meta = new + { + useLocalTime = true + }, + + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + } + } + }; + + const string route = "/operations"; + string contentType = ServerTimeMediaTypes.RelaxedAtomicOperationsWithRelaxedServerTime.ToString(); + + Action setRequestHeaders = headers => + headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(ServerTimeMediaTypes.RelaxedAtomicOperationsWithRelaxedServerTime.ToString())); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = + await _testContext.ExecutePostAsync(route, requestBody, contentType, setRequestHeaders); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(ServerTimeMediaTypes.RelaxedAtomicOperationsWithRelaxedServerTime.ToString()); + + responseDocument.Meta.Should().ContainKey("localServerTime"); + } + + [Fact] + public async Task Denies_JsonApi_ContentType_header_with_AtomicOperations_extension_and_ServerTime_at_resource_endpoint() + { + // Arrange + var requestBody = new + { + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + }; + + const string route = "/policies"; + string contentType = ServerTimeMediaTypes.AtomicOperationsWithServerTime.ToString(); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnsupportedMediaType); + + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); + + responseDocument.Errors.Should().HaveCount(1); + + string detail = $"Use '{JsonApiMediaType.Default}' or '{ServerTimeMediaTypes.ServerTime}' or " + + $"'{ServerTimeMediaTypes.RelaxedServerTime}' instead of '{contentType}' for the Content-Type header value."; + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); + error.Title.Should().Be("The specified Content-Type header value is not supported."); + error.Detail.Should().Be(detail); + error.Source.Should().NotBeNull(); + error.Source.Header.Should().Be("Content-Type"); + } + + [Fact] + public async Task Denies_JsonApi_ContentType_header_with_relaxed_ServerTime_at_operations_endpoint() + { + // Arrange + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "policies", + attributes = new + { + name = "some" + } + } + } + } + }; + + const string route = "/operations"; + string contentType = ServerTimeMediaTypes.RelaxedServerTime.ToString(); + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody, contentType); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnsupportedMediaType); + + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); + httpResponse.Content.Headers.ContentType.ToString().Should().Be(JsonApiMediaType.Default.ToString()); + + responseDocument.Errors.Should().HaveCount(1); + + string detail = $"Use '{JsonApiMediaType.AtomicOperations}' or '{ServerTimeMediaTypes.AtomicOperationsWithServerTime}' or " + + $"'{JsonApiMediaType.RelaxedAtomicOperations}' or '{ServerTimeMediaTypes.RelaxedAtomicOperationsWithRelaxedServerTime}' " + + $"instead of '{contentType}' for the Content-Type header value."; + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnsupportedMediaType); + error.Title.Should().Be("The specified Content-Type header value is not supported."); + error.Detail.Should().Be(detail); + error.Source.Should().NotBeNull(); + error.Source.Header.Should().Be("Content-Type"); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/RequestDocumentStore.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/RequestDocumentStore.cs new file mode 100644 index 0000000000..e972cdd078 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/RequestDocumentStore.cs @@ -0,0 +1,8 @@ +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation.CustomExtensions; + +internal sealed class RequestDocumentStore +{ + public Document? Document { get; set; } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeContentNegotiator.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeContentNegotiator.cs new file mode 100644 index 0000000000..e6e507e2b2 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeContentNegotiator.cs @@ -0,0 +1,59 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using Microsoft.AspNetCore.Http; + +namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation.CustomExtensions; + +internal sealed class ServerTimeContentNegotiator(IJsonApiOptions options, IHttpContextAccessor httpContextAccessor) + : JsonApiContentNegotiator(options, httpContextAccessor) +{ + private readonly IJsonApiOptions _options = options; + + protected override IReadOnlyList GetPossibleMediaTypes() + { + List mediaTypes = []; + + // Relaxed entries come after JSON:API compliant entries, which makes them less likely to be selected. + + if (IsOperationsEndpoint()) + { + if (_options.Extensions.Contains(JsonApiMediaTypeExtension.AtomicOperations)) + { + mediaTypes.Add(JsonApiMediaType.AtomicOperations); + } + + if (_options.Extensions.Contains(JsonApiMediaTypeExtension.AtomicOperations) && + _options.Extensions.Contains(ServerTimeMediaTypeExtension.ServerTime)) + { + mediaTypes.Add(ServerTimeMediaTypes.AtomicOperationsWithServerTime); + } + + if (_options.Extensions.Contains(JsonApiMediaTypeExtension.RelaxedAtomicOperations)) + { + mediaTypes.Add(JsonApiMediaType.RelaxedAtomicOperations); + } + + if (_options.Extensions.Contains(JsonApiMediaTypeExtension.RelaxedAtomicOperations) && + _options.Extensions.Contains(ServerTimeMediaTypeExtension.RelaxedServerTime)) + { + mediaTypes.Add(ServerTimeMediaTypes.RelaxedAtomicOperationsWithRelaxedServerTime); + } + } + else + { + mediaTypes.Add(JsonApiMediaType.Default); + + if (_options.Extensions.Contains(ServerTimeMediaTypeExtension.ServerTime)) + { + mediaTypes.Add(ServerTimeMediaTypes.ServerTime); + } + + if (_options.Extensions.Contains(ServerTimeMediaTypeExtension.RelaxedServerTime)) + { + mediaTypes.Add(ServerTimeMediaTypes.RelaxedServerTime); + } + } + + return mediaTypes.AsReadOnly(); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeMediaTypeExtension.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeMediaTypeExtension.cs new file mode 100644 index 0000000000..35756e99a8 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeMediaTypeExtension.cs @@ -0,0 +1,11 @@ +using JsonApiDotNetCore.Middleware; + +#pragma warning disable AV1008 // Class should not be static + +namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation.CustomExtensions; + +internal static class ServerTimeMediaTypeExtension +{ + public static readonly JsonApiMediaTypeExtension ServerTime = new("https://www.jsonapi.net/ext/server-time"); + public static readonly JsonApiMediaTypeExtension RelaxedServerTime = new("server-time"); +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeMediaTypes.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeMediaTypes.cs new file mode 100644 index 0000000000..8fd5f0d377 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeMediaTypes.cs @@ -0,0 +1,21 @@ +using JsonApiDotNetCore.Middleware; + +#pragma warning disable AV1008 // Class should not be static + +namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation.CustomExtensions; + +internal static class ServerTimeMediaTypes +{ + public static readonly JsonApiMediaType ServerTime = new([ServerTimeMediaTypeExtension.ServerTime]); + public static readonly JsonApiMediaType RelaxedServerTime = new([ServerTimeMediaTypeExtension.RelaxedServerTime]); + + public static readonly JsonApiMediaType AtomicOperationsWithServerTime = new([ + JsonApiMediaTypeExtension.AtomicOperations, + ServerTimeMediaTypeExtension.ServerTime + ]); + + public static readonly JsonApiMediaType RelaxedAtomicOperationsWithRelaxedServerTime = new([ + JsonApiMediaTypeExtension.RelaxedAtomicOperations, + ServerTimeMediaTypeExtension.RelaxedServerTime + ]); +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeResponseMeta.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeResponseMeta.cs new file mode 100644 index 0000000000..27487e0149 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeResponseMeta.cs @@ -0,0 +1,37 @@ +using System.Globalization; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Serialization.Response; + +namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation.CustomExtensions; + +internal sealed class ServerTimeResponseMeta(IJsonApiRequest request, RequestDocumentStore documentStore, TimeProvider timeProvider) : IResponseMeta +{ + private readonly IJsonApiRequest _request = request; + private readonly RequestDocumentStore _documentStore = documentStore; + private readonly TimeProvider _timeProvider = timeProvider; + + public IDictionary? GetMeta() + { + if (_request.Extensions.Contains(ServerTimeMediaTypeExtension.ServerTime) || + _request.Extensions.Contains(ServerTimeMediaTypeExtension.RelaxedServerTime)) + { + if (_documentStore.Document is not { Meta: not null } || !_documentStore.Document.Meta.TryGetValue("useLocalTime", out object? useLocalTimeValue) || + useLocalTimeValue == null || !bool.TryParse(useLocalTimeValue.ToString(), out bool useLocalTime)) + { + useLocalTime = false; + } + + return useLocalTime + ? new Dictionary + { + ["localServerTime"] = _timeProvider.GetLocalNow().ToString("O", CultureInfo.InvariantCulture) + } + : new Dictionary + { + ["utcServerTime"] = _timeProvider.GetUtcNow().UtcDateTime.ToString("O", CultureInfo.InvariantCulture) + }; + } + + return null; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/OperationsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/OperationsController.cs index d24a0f29d1..9792ed8830 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/OperationsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/OperationsController.cs @@ -7,11 +7,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation; -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 sealed class OperationsController( + IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request, + ITargetedFields targetedFields, IAtomicOperationFilter operationFilter) + : JsonApiOperationsController(options, resourceGraph, loggerFactory, processor, request, targetedFields, operationFilter); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/PolicyDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/PolicyDbContext.cs index 61063269ce..94f44189cf 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/PolicyDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/PolicyDbContext.cs @@ -1,15 +1,12 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class PolicyDbContext : DbContext +public sealed class PolicyDbContext(DbContextOptions options) + : TestableDbContext(options) { public DbSet Policies => Set(); - - public PolicyDbContext(DbContextOptions options) - : base(options) - { - } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultDbContext.cs index 63b748ffab..6d3e9e654a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultDbContext.cs @@ -1,15 +1,12 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; namespace JsonApiDotNetCoreTests.IntegrationTests.ControllerActionResults; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class ActionResultDbContext : DbContext +public sealed class ActionResultDbContext(DbContextOptions options) + : TestableDbContext(options) { public DbSet Toothbrushes => Set(); - - public ActionResultDbContext(DbContextOptions options) - : base(options) - { - } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs index b11c31623c..fe047449f1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs @@ -37,7 +37,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(toothbrush.StringId); } @@ -53,7 +53,7 @@ public async Task Converts_empty_ActionResult_to_error_collection() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -73,7 +73,7 @@ public async Task Converts_ActionResult_with_error_object_to_error_collection() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -93,7 +93,7 @@ public async Task Cannot_convert_ActionResult_with_string_parameter_to_error_col // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.InternalServerError); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.InternalServerError); @@ -113,7 +113,7 @@ public async Task Converts_ObjectResult_with_error_object_to_error_collection() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadGateway); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadGateway); @@ -133,7 +133,7 @@ public async Task Converts_ObjectResult_with_error_objects_to_error_collection() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(3); + responseDocument.Errors.Should().HaveCount(3); ErrorObject error1 = responseDocument.Errors[0]; error1.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs index 2170d113ce..5d8793b453 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs @@ -4,11 +4,6 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ControllerActionResults; -// Workaround for https://youtrack.jetbrains.com/issue/RSRP-487028 -public partial class ToothbrushesController -{ -} - partial class ToothbrushesController { internal const int EmptyActionResultId = 11111111; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeLogTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeLogTests.cs new file mode 100644 index 0000000000..132fa446b1 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeLogTests.cs @@ -0,0 +1,54 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes; + +public sealed class ApiControllerAttributeLogTests : IntegrationTestContext, CustomRouteDbContext>, IAsyncDisposable +{ + private readonly CapturingLoggerProvider _loggerProvider; + + public ApiControllerAttributeLogTests() + { + UseController(); + + _loggerProvider = new CapturingLoggerProvider(LogLevel.Warning); + + ConfigureLogging(options => + { + options.AddProvider(_loggerProvider); + + options.Services.AddSingleton(_loggerProvider); + }); + } + + [Fact] + public void Logs_warning_at_startup_when_ApiControllerAttribute_found() + { + // Arrange + _loggerProvider.Clear(); + + // Act + _ = Factory; + + // Assert + IReadOnlyList logLines = _loggerProvider.GetLines(); + logLines.Should().HaveCount(1); + + logLines[0].Should().Be( + $"[WARNING] Found JSON:API controller '{typeof(CiviliansController)}' with [ApiController]. Please remove this attribute for optimal JSON:API compliance."); + } + + public override Task DisposeAsync() + { + _loggerProvider.Dispose(); + return base.DisposeAsync(); + } + + async ValueTask IAsyncDisposable.DisposeAsync() + { + await DisposeAsync(); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs index 25404e25d2..731401137b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs @@ -29,10 +29,54 @@ public async Task ApiController_attribute_transforms_NotFound_action_result_with // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; - error.Links.ShouldNotBeNull(); - error.Links.About.Should().Be("https://tools.ietf.org/html/rfc7231#section-6.5.4"); + error.Links.Should().NotBeNull(); + error.Links.About.Should().StartWith("https://tools.ietf.org/html/rfc"); + } + + [Fact] + public async Task ProblemDetails_from_invalid_ModelState_is_translated_into_error_response() + { + // Arrange + var requestBody = new + { + data = new + { + type = "civilians", + attributes = new + { + name = (string?)null, + yearOfBirth = 1850 + } + } + }; + + const string route = "/world-civilians"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(2); + + ErrorObject error1 = responseDocument.Errors[0]; + error1.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error1.Links.Should().NotBeNull(); + error1.Links.About.Should().StartWith("https://tools.ietf.org/html/rfc"); + error1.Title.Should().Be("One or more validation errors occurred."); + error1.Detail.Should().Be("The Name field is required."); + error1.Source.Should().BeNull(); + + ErrorObject error2 = responseDocument.Errors[1]; + error2.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error2.Links.Should().NotBeNull(); + error2.Links.About.Should().StartWith("https://tools.ietf.org/html/rfc"); + error2.Title.Should().Be("One or more validation errors occurred."); + error2.Detail.Should().Be("The field YearOfBirth must be between 1900 and 2050."); + error2.Source.Should().BeNull(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/Civilian.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/Civilian.cs index 09fceeca60..00baaaa16c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/Civilian.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/Civilian.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; @@ -10,4 +11,8 @@ public sealed class Civilian : Identifiable { [Attr] public string Name { get; set; } = null!; + + [Attr] + [Range(1900, 2050)] + public int YearOfBirth { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CiviliansController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CiviliansController.cs index 313a8e8849..5d4da838c9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CiviliansController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CiviliansController.cs @@ -3,11 +3,6 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes; -// Workaround for https://youtrack.jetbrains.com/issue/RSRP-487028 -public partial class CiviliansController -{ -} - [ApiController] [DisableRoutingConvention] [Route("world-civilians")] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteDbContext.cs index 784e07bb4b..2228b1e304 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteDbContext.cs @@ -1,16 +1,13 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class CustomRouteDbContext : DbContext +public sealed class CustomRouteDbContext(DbContextOptions options) + : TestableDbContext(options) { public DbSet Towns => Set(); public DbSet Civilians => Set(); - - public CustomRouteDbContext(DbContextOptions options) - : base(options) - { - } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteFakers.cs index d9416d99ab..832993a626 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 +internal sealed class CustomRouteFakers { - 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() + .MakeDeterministic() + .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() + .MakeDeterministic() + .RuleFor(civilian => civilian.Name, faker => faker.Person.FullName)); public Faker Town => _lazyTownFaker.Value; public Faker Civilian => _lazyCivilianFaker.Value; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs index 572f2baeed..eb74e50fb4 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs @@ -25,8 +25,8 @@ public CustomRouteTests(IntegrationTestContext { @@ -42,24 +42,24 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Type.Should().Be("towns"); responseDocument.Data.SingleValue.Id.Should().Be(town.StringId); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(town.Name)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("latitude").With(value => value.Should().Be(town.Latitude)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("longitude").With(value => value.Should().Be(town.Longitude)); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("name").WhoseValue.Should().Be(town.Name); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("latitude").WhoseValue.Should().Be(town.Latitude); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("longitude").WhoseValue.Should().Be(town.Longitude); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("civilians").With(value => + responseDocument.Data.SingleValue.Relationships.Should().ContainKey("civilians").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{HostPrefix}{route}/relationships/civilians"); value.Links.Related.Should().Be($"{HostPrefix}{route}/civilians"); }); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.Should().NotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); } @@ -67,12 +67,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_get_resources_at_custom_action_method() { // Arrange - List town = _fakers.Town.Generate(7); + List towns = _fakers.Town.GenerateList(7); await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTableAsync(); - dbContext.Towns.AddRange(town); + dbContext.Towns.AddRange(towns); await dbContext.SaveChangesAsync(); }); @@ -84,9 +84,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(5); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Type == "towns"); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Attributes.ShouldNotBeNull().Any()); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ShouldNotBeNull().Any()); + responseDocument.Data.ManyValue.Should().HaveCount(5); + responseDocument.Data.ManyValue.Should().OnlyContain(resource => resource.Type == "towns"); + responseDocument.Data.ManyValue.Should().OnlyContain(resource => resource.Attributes != null && resource.Attributes.Count > 0); + responseDocument.Data.ManyValue.Should().OnlyContain(resource => resource.Relationships != null && resource.Relationships.Count > 0); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/TownsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/TownsController.cs index e3fab1ff3c..f99f3aa225 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/TownsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/CustomRoutes/TownsController.cs @@ -8,11 +8,6 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.CustomRoutes; -// Workaround for https://youtrack.jetbrains.com/issue/RSRP-487028 -public partial class TownsController -{ -} - [DisableRoutingConvention] [Route("world-api/civilization/popular/towns")] partial class TownsController diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Building.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Building.cs index ea3805cb22..78ab514636 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Building.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Building.cs @@ -14,12 +14,12 @@ public sealed class Building : Identifiable [Attr] public string Number { get; set; } = null!; - [NotMapped] [Attr] + [NotMapped] public int WindowCount => Windows.Count; - [NotMapped] [Attr(Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowChange)] + [NotMapped] public string PrimaryDoorColor { get @@ -50,8 +50,8 @@ public string PrimaryDoorColor } } - [NotMapped] [Attr(Capabilities = AttrCapabilities.AllowView)] + [NotMapped] public string? SecondaryDoorColor => SecondaryDoor?.Color; [EagerLoad] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingDefinition.cs index a1ebb2c87f..dbdfea8a6f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/BuildingDefinition.cs @@ -1,5 +1,4 @@ using JetBrains.Annotations; -using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; @@ -14,7 +13,7 @@ public sealed class BuildingDefinition : JsonApiResourceDefinition +public sealed class BuildingRepository( + ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, + IEnumerable constraintProviders, ILoggerFactory loggerFactory, IResourceDefinitionAccessor resourceDefinitionAccessor) + : EntityFrameworkCoreRepository(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, + resourceDefinitionAccessor) { - public BuildingRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, - IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory, resourceDefinitionAccessor) - { - } - public override async Task GetForCreateAsync(Type resourceClrType, int id, CancellationToken cancellationToken) { Building building = await base.GetForCreateAsync(resourceClrType, id, cancellationToken); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/City.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/City.cs index fea280724b..66e8dacb2c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/City.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/City.cs @@ -5,6 +5,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading; [UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.EagerLoading")] public sealed class City : Identifiable { [Attr] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingDbContext.cs index 01f81cd319..e01e0a4231 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingDbContext.cs @@ -1,23 +1,20 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; // @formatter:wrap_chained_method_calls chop_always namespace JsonApiDotNetCoreTests.IntegrationTests.EagerLoading; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class EagerLoadingDbContext : DbContext +public sealed class EagerLoadingDbContext(DbContextOptions options) + : TestableDbContext(options) { public DbSet States => Set(); public DbSet Streets => Set(); public DbSet Buildings => Set(); public DbSet Doors => Set(); - public EagerLoadingDbContext(DbContextOptions options) - : base(options) - { - } - protected override void OnModelCreating(ModelBuilder builder) { builder.Entity() @@ -33,5 +30,7 @@ protected override void OnModelCreating(ModelBuilder builder) .HasOne(building => building.SecondaryDoor) .WithOne() .HasForeignKey("SecondaryDoorId"); + + base.OnModelCreating(builder); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingFakers.cs index 0ac7eb6804..73ad80ce33 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 +internal sealed class EagerLoadingFakers { - 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() + .MakeDeterministic() + .RuleFor(state => state.Name, faker => faker.Address.City())); + + private readonly Lazy> _lazyCityFaker = new(() => new Faker() + .MakeDeterministic() + .RuleFor(city => city.Name, faker => faker.Address.City())); + + private readonly Lazy> _lazyStreetFaker = new(() => new Faker() + .MakeDeterministic() + .RuleFor(street => street.Name, faker => faker.Address.StreetName())); + + private readonly Lazy> _lazyBuildingFaker = new(() => new Faker() + .MakeDeterministic() + .RuleFor(building => building.Number, faker => faker.Address.BuildingNumber())); + + private readonly Lazy> _lazyWindowFaker = new(() => new Faker() + .MakeDeterministic() + .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() + .MakeDeterministic() + .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..8b64dfcbbf 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs @@ -21,7 +21,7 @@ public EagerLoadingTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceDefinition(); services.AddResourceRepository(); @@ -32,10 +32,10 @@ public EagerLoadingTests(IntegrationTestContext { @@ -51,27 +51,27 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(building.StringId); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("number").With(value => value.Should().Be(building.Number)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("windowCount").With(value => value.Should().Be(4)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("primaryDoorColor").With(value => value.Should().Be(building.PrimaryDoor.Color)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("secondaryDoorColor").With(value => value.Should().Be(building.SecondaryDoor.Color)); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("number").WhoseValue.Should().Be(building.Number); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("windowCount").WhoseValue.Should().Be(4); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("primaryDoorColor").WhoseValue.Should().Be(building.PrimaryDoor.Color); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("secondaryDoorColor").WhoseValue.Should().Be(building.SecondaryDoor.Color); } [Fact] public async Task Can_get_primary_resource_with_nested_eager_loads() { // Arrange - Street street = _fakers.Street.Generate(); - street.Buildings = _fakers.Building.Generate(2); + Street street = _fakers.Street.GenerateOne(); + street.Buildings = _fakers.Building.GenerateList(2); - street.Buildings[0].Windows = _fakers.Window.Generate(2); - street.Buildings[0].PrimaryDoor = _fakers.Door.Generate(); + street.Buildings[0].Windows = _fakers.Window.GenerateList(2); + street.Buildings[0].PrimaryDoor = _fakers.Door.GenerateOne(); - street.Buildings[1].Windows = _fakers.Window.Generate(3); - street.Buildings[1].PrimaryDoor = _fakers.Door.Generate(); - street.Buildings[1].SecondaryDoor = _fakers.Door.Generate(); + street.Buildings[1].Windows = _fakers.Window.GenerateList(3); + street.Buildings[1].PrimaryDoor = _fakers.Door.GenerateOne(); + street.Buildings[1].SecondaryDoor = _fakers.Door.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -87,22 +87,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(street.StringId); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(street.Name)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("buildingCount").With(value => value.Should().Be(2)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("doorTotalCount").With(value => value.Should().Be(3)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("windowTotalCount").With(value => value.Should().Be(5)); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("name").WhoseValue.Should().Be(street.Name); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("buildingCount").WhoseValue.Should().Be(2); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("doorTotalCount").WhoseValue.Should().Be(3); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("windowTotalCount").WhoseValue.Should().Be(5); } [Fact] public async Task Can_get_primary_resource_with_fieldset() { // Arrange - Street street = _fakers.Street.Generate(); - street.Buildings = _fakers.Building.Generate(1); - street.Buildings[0].Windows = _fakers.Window.Generate(3); - street.Buildings[0].PrimaryDoor = _fakers.Door.Generate(); + Street street = _fakers.Street.GenerateOne(); + street.Buildings = _fakers.Building.GenerateList(1); + street.Buildings[0].Windows = _fakers.Window.GenerateList(3); + street.Buildings[0].PrimaryDoor = _fakers.Door.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -118,10 +118,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(street.StringId); - responseDocument.Data.SingleValue.Attributes.ShouldHaveCount(1); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("windowTotalCount").With(value => value.Should().Be(3)); + responseDocument.Data.SingleValue.Attributes.Should().HaveCount(1); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("windowTotalCount").WhoseValue.Should().Be(3); responseDocument.Data.SingleValue.Relationships.Should().BeNull(); } @@ -129,12 +129,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_get_primary_resource_with_includes() { // Arrange - State state = _fakers.State.Generate(); - state.Cities = _fakers.City.Generate(1); - state.Cities[0].Streets = _fakers.Street.Generate(1); - state.Cities[0].Streets[0].Buildings = _fakers.Building.Generate(1); - state.Cities[0].Streets[0].Buildings[0].PrimaryDoor = _fakers.Door.Generate(); - state.Cities[0].Streets[0].Buildings[0].Windows = _fakers.Window.Generate(3); + State state = _fakers.State.GenerateOne(); + state.Cities = _fakers.City.GenerateList(1); + state.Cities[0].Streets = _fakers.Street.GenerateList(1); + state.Cities[0].Streets[0].Buildings = _fakers.Building.GenerateList(1); + state.Cities[0].Streets[0].Buildings[0].PrimaryDoor = _fakers.Door.GenerateOne(); + state.Cities[0].Streets[0].Buildings[0].Windows = _fakers.Window.GenerateList(3); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -150,34 +150,34 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(state.StringId); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(state.Name)); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("name").WhoseValue.Should().Be(state.Name); - responseDocument.Included.ShouldHaveCount(2); + responseDocument.Included.Should().HaveCount(2); responseDocument.Included[0].Type.Should().Be("cities"); responseDocument.Included[0].Id.Should().Be(state.Cities[0].StringId); - responseDocument.Included[0].Attributes.ShouldContainKey("name").With(value => value.Should().Be(state.Cities[0].Name)); + responseDocument.Included[0].Attributes.Should().ContainKey("name").WhoseValue.Should().Be(state.Cities[0].Name); responseDocument.Included[1].Type.Should().Be("streets"); responseDocument.Included[1].Id.Should().Be(state.Cities[0].Streets[0].StringId); - responseDocument.Included[1].Attributes.ShouldContainKey("buildingCount").With(value => value.Should().Be(1)); - responseDocument.Included[1].Attributes.ShouldContainKey("doorTotalCount").With(value => value.Should().Be(1)); - responseDocument.Included[1].Attributes.ShouldContainKey("windowTotalCount").With(value => value.Should().Be(3)); + responseDocument.Included[1].Attributes.Should().ContainKey("buildingCount").WhoseValue.Should().Be(1); + responseDocument.Included[1].Attributes.Should().ContainKey("doorTotalCount").WhoseValue.Should().Be(1); + responseDocument.Included[1].Attributes.Should().ContainKey("windowTotalCount").WhoseValue.Should().Be(3); } [Fact] public async Task Can_get_secondary_resources_with_include_and_fieldsets() { // Arrange - State state = _fakers.State.Generate(); - state.Cities = _fakers.City.Generate(1); - state.Cities[0].Streets = _fakers.Street.Generate(1); - state.Cities[0].Streets[0].Buildings = _fakers.Building.Generate(1); - state.Cities[0].Streets[0].Buildings[0].PrimaryDoor = _fakers.Door.Generate(); - state.Cities[0].Streets[0].Buildings[0].SecondaryDoor = _fakers.Door.Generate(); - state.Cities[0].Streets[0].Buildings[0].Windows = _fakers.Window.Generate(1); + State state = _fakers.State.GenerateOne(); + state.Cities = _fakers.City.GenerateList(1); + state.Cities[0].Streets = _fakers.Street.GenerateList(1); + state.Cities[0].Streets[0].Buildings = _fakers.Building.GenerateList(1); + state.Cities[0].Streets[0].Buildings[0].PrimaryDoor = _fakers.Door.GenerateOne(); + state.Cities[0].Streets[0].Buildings[0].SecondaryDoor = _fakers.Door.GenerateOne(); + state.Cities[0].Streets[0].Buildings[0].Windows = _fakers.Window.GenerateList(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -193,18 +193,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(state.Cities[0].StringId); - responseDocument.Data.ManyValue[0].Attributes.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("name").With(value => value.Should().Be(state.Cities[0].Name)); + responseDocument.Data.ManyValue[0].Attributes.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("name").WhoseValue.Should().Be(state.Cities[0].Name); responseDocument.Data.ManyValue[0].Relationships.Should().BeNull(); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Type.Should().Be("streets"); responseDocument.Included[0].Id.Should().Be(state.Cities[0].Streets[0].StringId); - responseDocument.Included[0].Attributes.ShouldHaveCount(2); - responseDocument.Included[0].Attributes.ShouldContainKey("doorTotalCount").With(value => value.Should().Be(2)); - responseDocument.Included[0].Attributes.ShouldContainKey("windowTotalCount").With(value => value.Should().Be(1)); + responseDocument.Included[0].Attributes.Should().HaveCount(2); + responseDocument.Included[0].Attributes.Should().ContainKey("doorTotalCount").WhoseValue.Should().Be(2); + responseDocument.Included[0].Attributes.Should().ContainKey("windowTotalCount").WhoseValue.Should().Be(1); responseDocument.Included[0].Relationships.Should().BeNull(); } @@ -212,7 +212,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_resource() { // Arrange - Building newBuilding = _fakers.Building.Generate(); + Building newBuilding = _fakers.Building.GenerateOne(); var requestBody = new { @@ -234,18 +234,18 @@ public async Task Can_create_resource() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("number").With(value => value.Should().Be(newBuilding.Number)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("windowCount").With(value => value.Should().Be(0)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("primaryDoorColor").With(value => value.Should().Be("(unspecified)")); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("secondaryDoorColor").With(value => value.Should().BeNull()); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("number").WhoseValue.Should().Be(newBuilding.Number); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("windowCount").WhoseValue.Should().Be(0); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("primaryDoorColor").WhoseValue.Should().Be("(unspecified)"); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("secondaryDoorColor").WhoseValue.Should().BeNull(); - int newBuildingId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + int newBuildingId = int.Parse(responseDocument.Data.SingleValue.Id.Should().NotBeNull().And.Subject); 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,12 +253,12 @@ 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(); + buildingInDatabase.Should().NotBeNull(); buildingInDatabase.Number.Should().Be(newBuilding.Number); - buildingInDatabase.PrimaryDoor.ShouldNotBeNull(); + buildingInDatabase.PrimaryDoor.Should().NotBeNull(); buildingInDatabase.PrimaryDoor.Color.Should().Be("(unspecified)"); buildingInDatabase.SecondaryDoor.Should().BeNull(); buildingInDatabase.Windows.Should().BeEmpty(); @@ -269,13 +269,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_update_resource() { // Arrange - Building existingBuilding = _fakers.Building.Generate(); - existingBuilding.PrimaryDoor = _fakers.Door.Generate(); - existingBuilding.SecondaryDoor = _fakers.Door.Generate(); - existingBuilding.Windows = _fakers.Window.Generate(2); + Building existingBuilding = _fakers.Building.GenerateOne(); + existingBuilding.PrimaryDoor = _fakers.Door.GenerateOne(); + existingBuilding.SecondaryDoor = _fakers.Door.GenerateOne(); + existingBuilding.Windows = _fakers.Window.GenerateList(2); - string newBuildingNumber = _fakers.Building.Generate().Number; - string newPrimaryDoorColor = _fakers.Door.Generate().Color; + string newBuildingNumber = _fakers.Building.GenerateOne().Number; + string newPrimaryDoorColor = _fakers.Door.GenerateOne().Color; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -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,15 +318,15 @@ 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(); + buildingInDatabase.Should().NotBeNull(); buildingInDatabase.Number.Should().Be(newBuildingNumber); - buildingInDatabase.PrimaryDoor.ShouldNotBeNull(); + buildingInDatabase.PrimaryDoor.Should().NotBeNull(); buildingInDatabase.PrimaryDoor.Color.Should().Be(newPrimaryDoorColor); - buildingInDatabase.SecondaryDoor.ShouldNotBeNull(); - buildingInDatabase.Windows.ShouldHaveCount(2); + buildingInDatabase.SecondaryDoor.Should().NotBeNull(); + buildingInDatabase.Windows.Should().HaveCount(2); }); } @@ -334,8 +334,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_update_resource_when_primaryDoorColor_is_set_to_null() { // Arrange - Building existingBuilding = _fakers.Building.Generate(); - existingBuilding.PrimaryDoor = _fakers.Door.Generate(); + Building existingBuilding = _fakers.Building.GenerateOne(); + existingBuilding.PrimaryDoor = _fakers.Door.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -364,13 +364,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Input validation failed."); error.Detail.Should().Be("The PrimaryDoorColor field is required."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/data/attributes/primaryDoorColor"); } @@ -378,8 +378,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_delete_resource() { // Arrange - Building existingBuilding = _fakers.Building.Generate(); - existingBuilding.PrimaryDoor = _fakers.Door.Generate(); + Building existingBuilding = _fakers.Building.GenerateOne(); + existingBuilding.PrimaryDoor = _fakers.Door.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Street.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Street.cs index db440ad85d..263bb6cfd4 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Street.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/EagerLoading/Street.cs @@ -12,16 +12,16 @@ public sealed class Street : Identifiable [Attr] public string Name { get; set; } = null!; - [NotMapped] [Attr(Capabilities = AttrCapabilities.AllowView)] + [NotMapped] public int BuildingCount => Buildings.Count; - [NotMapped] [Attr(Capabilities = AttrCapabilities.AllowView)] + [NotMapped] public int DoorTotalCount => Buildings.Sum(building => building.SecondaryDoor == null ? 1 : 2); - [NotMapped] [Attr(Capabilities = AttrCapabilities.AllowView)] + [NotMapped] public int WindowTotalCount => Buildings.Sum(building => building.WindowCount); [EagerLoad] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/AlternateExceptionHandler.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/AlternateExceptionHandler.cs index 4af459fb71..703cec35cc 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/AlternateExceptionHandler.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/AlternateExceptionHandler.cs @@ -5,13 +5,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ExceptionHandling; -public sealed class AlternateExceptionHandler : ExceptionHandler +public sealed class AlternateExceptionHandler(ILoggerFactory loggerFactory, IJsonApiOptions options) + : ExceptionHandler(loggerFactory, options) { - public AlternateExceptionHandler(ILoggerFactory loggerFactory, IJsonApiOptions options) - : base(loggerFactory, options) - { - } - protected override LogLevel GetLogLevel(Exception exception) { if (exception is ConsumerArticleIsNoLongerAvailableException) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticleIsNoLongerAvailableException.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticleIsNoLongerAvailableException.cs index 6c913ac04b..18341374f6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticleIsNoLongerAvailableException.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticleIsNoLongerAvailableException.cs @@ -4,17 +4,12 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ExceptionHandling; -internal sealed class ConsumerArticleIsNoLongerAvailableException : JsonApiException -{ - public string SupportEmailAddress { get; } - - public ConsumerArticleIsNoLongerAvailableException(string articleCode, string supportEmailAddress) - : base(new ErrorObject(HttpStatusCode.Gone) - { - Title = "The requested article is no longer available.", - Detail = $"Article with code '{articleCode}' is no longer available." - }) +internal sealed class ConsumerArticleIsNoLongerAvailableException(string articleCode, string supportEmailAddress) + : JsonApiException(new ErrorObject(HttpStatusCode.Gone) { - SupportEmailAddress = supportEmailAddress; - } + Title = "The requested article is no longer available.", + Detail = $"Article with code '{articleCode}' is no longer available." + }) +{ + public string SupportEmailAddress { get; } = supportEmailAddress; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs index 335bf7e9fb..9c2a9fde48 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs @@ -10,18 +10,16 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ExceptionHandling; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class ConsumerArticleService : JsonApiResourceService +public sealed class ConsumerArticleService( + IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, + ILoggerFactory loggerFactory, IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, + IResourceDefinitionAccessor resourceDefinitionAccessor) + : JsonApiResourceService(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, + resourceChangeTracker, resourceDefinitionAccessor) { private const string SupportEmailAddress = "company@email.com"; internal const string UnavailableArticlePrefix = "X"; - public ConsumerArticleService(IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, - IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, - IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, resourceDefinitionAccessor) - { - } - public override async Task GetAsync(int id, CancellationToken cancellationToken) { ConsumerArticle consumerArticle = await base.GetAsync(id, cancellationToken); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ErrorDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ErrorDbContext.cs index 141dfc4f71..c2600dc3d6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ErrorDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ErrorDbContext.cs @@ -1,16 +1,13 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; namespace JsonApiDotNetCoreTests.IntegrationTests.ExceptionHandling; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class ErrorDbContext : DbContext +public sealed class ErrorDbContext(DbContextOptions options) + : TestableDbContext(options) { public DbSet ConsumerArticles => Set(); public DbSet ThrowingArticles => Set(); - - public ErrorDbContext(DbContextOptions options) - : base(options) - { - } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs index 53a2415627..35a5364938 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs @@ -22,22 +22,18 @@ public ExceptionHandlerTests(IntegrationTestContext(); testContext.UseController(); - var loggerFactory = new FakeLoggerFactory(LogLevel.Warning); - testContext.ConfigureLogging(options => { - options.ClearProviders(); - options.AddProvider(loggerFactory); - }); + var loggerProvider = new CapturingLoggerProvider(LogLevel.Warning); + options.AddProvider(loggerProvider); - testContext.ConfigureServicesBeforeStartup(services => - { - services.AddSingleton(loggerFactory); + options.Services.AddSingleton(loggerProvider); }); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceService(); + services.AddScoped(); }); } @@ -46,8 +42,8 @@ public ExceptionHandlerTests(IntegrationTestContext(); - loggerFactory.Logger.Clear(); + var loggerProvider = _testContext.Factory.Services.GetRequiredService(); + loggerProvider.Clear(); var consumerArticle = new ConsumerArticle { @@ -68,14 +64,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Gone); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.Gone); error.Title.Should().Be("The requested article is no longer available."); error.Detail.Should().Be("Article with code 'X123' is no longer available."); - error.Meta.ShouldContainKey("support").With(value => + error.Meta.Should().ContainKey("support").WhoseValue.With(value => { JsonElement element = value.Should().BeOfType().Subject; element.GetString().Should().Be("Please contact us for info about similar articles at company@email.com."); @@ -83,19 +79,21 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Meta.Should().BeNull(); - loggerFactory.Logger.Messages.ShouldHaveCount(1); - loggerFactory.Logger.Messages.Single().LogLevel.Should().Be(LogLevel.Warning); - loggerFactory.Logger.Messages.Single().Text.Should().Contain("Article with code 'X123' is no longer available."); + IReadOnlyList logMessages = loggerProvider.GetMessages(); + logMessages.Should().HaveCount(1); + + logMessages[0].LogLevel.Should().Be(LogLevel.Warning); + logMessages[0].Text.Should().Contain("Article with code 'X123' is no longer available."); } [Fact] public async Task Logs_and_produces_error_response_on_deserialization_failure() { // Arrange - var loggerFactory = _testContext.Factory.Services.GetRequiredService(); - loggerFactory.Logger.Clear(); + var loggerProvider = _testContext.Factory.Services.GetRequiredService(); + loggerProvider.Clear(); - const string requestBody = @"{ ""data"": { ""type"": """" } }"; + const string requestBody = """{ "data": { "type": "" } }"""; const string route = "/consumerArticles"; @@ -105,36 +103,25 @@ public async Task Logs_and_produces_error_response_on_deserialization_failure() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body: Unknown resource type found."); error.Detail.Should().Be("Resource type '' does not exist."); + error.Meta.Should().ContainRequestBody(requestBody); + error.Meta.Should().HaveStackTrace(); - error.Meta.ShouldContainKey("requestBody").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetString().Should().Be(requestBody); - }); - - error.Meta.ShouldContainKey("stackTrace").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - IEnumerable stackTraceLines = element.EnumerateArray().Select(token => token.GetString()); - - stackTraceLines.ShouldNotBeEmpty(); - }); - - loggerFactory.Logger.Messages.Should().BeEmpty(); + IReadOnlyList logMessages = loggerProvider.GetMessages(); + logMessages.Should().BeEmpty(); } [Fact] public async Task Logs_and_produces_error_response_on_serialization_failure() { // Arrange - var loggerFactory = _testContext.Factory.Services.GetRequiredService(); - loggerFactory.Logger.Clear(); + var loggerProvider = _testContext.Factory.Services.GetRequiredService(); + loggerProvider.Clear(); var throwingArticle = new ThrowingArticle(); @@ -152,25 +139,20 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.InternalServerError); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.InternalServerError); error.Title.Should().Be("An unhandled error occurred while processing this request."); error.Detail.Should().Be("Exception has been thrown by the target of an invocation."); - - error.Meta.ShouldContainKey("stackTrace").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - IEnumerable stackTraceLines = element.EnumerateArray().Select(token => token.GetString()); - - stackTraceLines.Should().ContainMatch("*ThrowingArticle*"); - }); + error.Meta.Should().HaveInStackTrace("*ThrowingArticle*"); responseDocument.Meta.Should().BeNull(); - loggerFactory.Logger.Messages.ShouldHaveCount(1); - loggerFactory.Logger.Messages.Single().LogLevel.Should().Be(LogLevel.Error); - loggerFactory.Logger.Messages.Single().Text.Should().Contain("Exception has been thrown by the target of an invocation."); + IReadOnlyList logMessages = loggerProvider.GetMessages(); + logMessages.Should().HaveCount(1); + + logMessages[0].LogLevel.Should().Be(LogLevel.Error); + logMessages[0].Text.Should().Contain("Exception has been thrown by the target of an invocation."); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/HitCountingResourceDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/HitCountingResourceDefinition.cs index 8132728c90..8cd361ef99 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/HitCountingResourceDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/HitCountingResourceDefinition.cs @@ -1,5 +1,4 @@ using System.Collections.Immutable; -using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; @@ -22,7 +21,7 @@ public abstract class HitCountingResourceDefinition : JsonApiRes protected HitCountingResourceDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter) : base(resourceGraph) { - ArgumentGuard.NotNull(hitCounter, nameof(hitCounter)); + ArgumentNullException.ThrowIfNull(hitCounter); _hitCounter = hitCounter; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingDbContext.cs index e92dae1318..2df6d9e390 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingDbContext.cs @@ -1,16 +1,13 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; namespace JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class HostingDbContext : DbContext +public sealed class HostingDbContext(DbContextOptions options) + : TestableDbContext(options) { public DbSet ArtGalleries => Set(); public DbSet Paintings => Set(); - - public HostingDbContext(DbContextOptions options) - : base(options) - { - } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingFakers.cs index 93734a6f08..8fb0944c32 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 +internal sealed class HostingFakers { - private readonly Lazy> _lazyArtGalleryFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(artGallery => artGallery.Theme, faker => faker.Lorem.Word())); + private readonly Lazy> _lazyArtGalleryFaker = new(() => new Faker() + .MakeDeterministic() + .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() + .MakeDeterministic() + .RuleFor(painting => painting.Title, faker => faker.Lorem.Sentence())); public Faker ArtGallery => _lazyArtGalleryFaker.Value; public Faker Painting => _lazyPaintingFaker.Value; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingStartup.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingStartup.cs index a22dfe5954..1653cd5e96 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingStartup.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingStartup.cs @@ -1,14 +1,13 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using Microsoft.AspNetCore.Builder; -using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; namespace JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public sealed class HostingStartup : TestableStartup - where TDbContext : DbContext + where TDbContext : TestableDbContext { protected override void SetJsonApiOptions(JsonApiOptions options) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingTests.cs index 872cd44c3a..d3ab6edd78 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/HostingTests.cs @@ -25,8 +25,8 @@ public HostingTests(IntegrationTestContext, Hos public async Task Get_primary_resources_with_include_returns_links() { // Arrange - ArtGallery gallery = _fakers.ArtGallery.Generate(); - gallery.Paintings = _fakers.Painting.Generate(1).ToHashSet(); + ArtGallery gallery = _fakers.ArtGallery.GenerateOne(); + gallery.Paintings = _fakers.Painting.GenerateSet(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -43,7 +43,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().Be(responseDocument.Links.Self); @@ -51,19 +51,19 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].With(resource => { string galleryLink = $"{HostPrefix}/iis-application-virtual-directory/public-api/artGalleries/{gallery.StringId}"; - resource.Links.ShouldNotBeNull(); + resource.Links.Should().NotBeNull(); resource.Links.Self.Should().Be(galleryLink); - resource.Relationships.ShouldContainKey("paintings").With(value => + resource.Relationships.Should().ContainKey("paintings").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{galleryLink}/relationships/paintings"); value.Links.Related.Should().Be($"{galleryLink}/paintings"); }); @@ -71,17 +71,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => string paintingLink = $"{HostPrefix}/iis-application-virtual-directory/custom/path/to/paintings-of-the-world/{gallery.Paintings.ElementAt(0).StringId}"; - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].With(resource => { - resource.Links.ShouldNotBeNull(); + resource.Links.Should().NotBeNull(); resource.Links.Self.Should().Be(paintingLink); - resource.Relationships.ShouldContainKey("exposedAt").With(value => + resource.Relationships.Should().ContainKey("exposedAt").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{paintingLink}/relationships/exposedAt"); value.Links.Related.Should().Be($"{paintingLink}/exposedAt"); }); @@ -92,8 +92,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Get_primary_resources_with_include_on_custom_route_returns_links() { // Arrange - Painting painting = _fakers.Painting.Generate(); - painting.ExposedAt = _fakers.ArtGallery.Generate(); + Painting painting = _fakers.Painting.GenerateOne(); + painting.ExposedAt = _fakers.ArtGallery.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -110,7 +110,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().Be(responseDocument.Links.Self); @@ -118,37 +118,37 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].With(resource => { string paintingLink = $"{HostPrefix}/iis-application-virtual-directory/custom/path/to/paintings-of-the-world/{painting.StringId}"; - resource.Links.ShouldNotBeNull(); + resource.Links.Should().NotBeNull(); resource.Links.Self.Should().Be(paintingLink); - resource.Relationships.ShouldContainKey("exposedAt").With(value => + resource.Relationships.Should().ContainKey("exposedAt").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{paintingLink}/relationships/exposedAt"); value.Links.Related.Should().Be($"{paintingLink}/exposedAt"); }); }); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].With(resource => { string galleryLink = $"{HostPrefix}/iis-application-virtual-directory/public-api/artGalleries/{painting.ExposedAt.StringId}"; - resource.Links.ShouldNotBeNull(); + resource.Links.Should().NotBeNull(); resource.Links.Self.Should().Be(galleryLink); - resource.Relationships.ShouldContainKey("paintings").With(value => + resource.Relationships.Should().ContainKey("paintings").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{galleryLink}/relationships/paintings"); value.Links.Related.Should().Be($"{galleryLink}/paintings"); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/PaintingsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/PaintingsController.cs index bf7b915f03..c27e628642 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/PaintingsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/HostingInIIS/PaintingsController.cs @@ -3,13 +3,6 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.HostingInIIS; -// Workaround for https://youtrack.jetbrains.com/issue/RSRP-487028 -public partial class PaintingsController -{ -} - [DisableRoutingConvention] [Route("custom/path/to/paintings-of-the-world")] -partial class PaintingsController -{ -} +partial class PaintingsController; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccount.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccount.cs index 911054f291..663b2a3bd6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccount.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccount.cs @@ -1,9 +1,11 @@ using JetBrains.Annotations; +using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation; [UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(GenerateControllerEndpoints = JsonApiEndpoints.None)] public sealed class BankAccount : ObfuscatedIdentifiable { [Attr] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccountsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccountsController.cs index eedc36ff87..b34fe3a321 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccountsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/BankAccountsController.cs @@ -4,11 +4,6 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation; -public sealed class BankAccountsController : ObfuscatedIdentifiableController -{ - public BankAccountsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, resourceGraph, loggerFactory, resourceService) - { - } -} +public sealed class BankAccountsController( + IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceService resourceService) + : ObfuscatedIdentifiableController(options, resourceGraph, loggerFactory, resourceService); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCard.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCard.cs index 323d1bd1e3..99f0154147 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCard.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCard.cs @@ -1,9 +1,11 @@ using JetBrains.Annotations; +using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation; [UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(GenerateControllerEndpoints = JsonApiEndpoints.None)] public sealed class DebitCard : ObfuscatedIdentifiable { [Attr] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCardsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCardsController.cs index e8c426572a..7fb26d5708 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCardsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/DebitCardsController.cs @@ -4,11 +4,6 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation; -public sealed class DebitCardsController : ObfuscatedIdentifiableController -{ - public DebitCardsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, resourceGraph, loggerFactory, resourceService) - { - } -} +public sealed class DebitCardsController( + IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceService resourceService) + : ObfuscatedIdentifiableController(options, resourceGraph, loggerFactory, resourceService); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs index 1e95efa717..a3feb15b9e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs @@ -15,7 +15,7 @@ public int Decode(string? value) return 0; } - if (!value.StartsWith("x", StringComparison.Ordinal)) + if (!value.StartsWith('x')) { throw new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest) { @@ -39,7 +39,7 @@ private static string FromHexString(string hexString) bytes.Add(bt); } - char[] chars = Encoding.ASCII.GetChars(bytes.ToArray()); + char[] chars = Encoding.ASCII.GetChars([.. bytes]); return new string(chars); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs index e9ed2f789c..e5383f3f7d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs @@ -24,7 +24,7 @@ public IdObfuscationTests(IntegrationTestContext accounts = _fakers.BankAccount.Generate(2); + List accounts = _fakers.BankAccount.GenerateList(2); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -41,15 +41,38 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(accounts[1].StringId); } + [Fact] + public async Task Cannot_filter_equality_for_invalid_ID() + { + // Arrange + var parameterValue = new MarkedText("equals(id,^'not-a-hex-value')", '^'); + string route = $"/bankAccounts?filter={parameterValue.Text}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"The value 'not-a-hex-value' is not a valid hexadecimal value. {parameterValue}"); + error.Source.Should().NotBeNull(); + error.Source.Parameter.Should().Be("filter"); + } + [Fact] public async Task Can_filter_any_in_primary_resources() { // Arrange - List accounts = _fakers.BankAccount.Generate(2); + List accounts = _fakers.BankAccount.GenerateList(2); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -67,7 +90,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(accounts[1].StringId); } @@ -83,7 +106,7 @@ public async Task Cannot_get_primary_resource_for_invalid_ID() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); @@ -95,8 +118,8 @@ public async Task Cannot_get_primary_resource_for_invalid_ID() public async Task Can_get_primary_resource_by_ID() { // Arrange - DebitCard card = _fakers.DebitCard.Generate(); - card.Account = _fakers.BankAccount.Generate(); + DebitCard card = _fakers.DebitCard.GenerateOne(); + card.Account = _fakers.BankAccount.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -112,7 +135,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(card.StringId); } @@ -120,8 +143,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_get_secondary_resources() { // Arrange - BankAccount account = _fakers.BankAccount.Generate(); - account.Cards = _fakers.DebitCard.Generate(2); + BankAccount account = _fakers.BankAccount.GenerateOne(); + account.Cards = _fakers.DebitCard.GenerateList(2); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -137,7 +160,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue.Should().HaveCount(2); responseDocument.Data.ManyValue[0].Id.Should().Be(account.Cards[0].StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(account.Cards[1].StringId); } @@ -146,8 +169,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_include_resource_with_sparse_fieldset() { // Arrange - BankAccount account = _fakers.BankAccount.Generate(); - account.Cards = _fakers.DebitCard.Generate(1); + BankAccount account = _fakers.BankAccount.GenerateOne(); + account.Cards = _fakers.DebitCard.GenerateList(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -163,12 +186,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(account.StringId); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Id.Should().Be(account.Cards[0].StringId); - responseDocument.Included[0].Attributes.ShouldHaveCount(1); + responseDocument.Included[0].Attributes.Should().HaveCount(1); responseDocument.Included[0].Relationships.Should().BeNull(); } @@ -176,8 +199,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_get_relationship() { // Arrange - BankAccount account = _fakers.BankAccount.Generate(); - account.Cards = _fakers.DebitCard.Generate(1); + BankAccount account = _fakers.BankAccount.GenerateOne(); + account.Cards = _fakers.DebitCard.GenerateList(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -193,7 +216,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(account.Cards[0].StringId); } @@ -201,8 +224,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_resource_with_relationship() { // Arrange - BankAccount existingAccount = _fakers.BankAccount.Generate(); - DebitCard newCard = _fakers.DebitCard.Generate(); + BankAccount existingAccount = _fakers.BankAccount.GenerateOne(); + DebitCard newCard = _fakers.DebitCard.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -242,9 +265,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("ownerName").With(value => value.Should().Be(newCard.OwnerName)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("pinCode").With(value => value.Should().Be(newCard.PinCode)); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("ownerName").WhoseValue.Should().Be(newCard.OwnerName); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("pinCode").WhoseValue.Should().Be(newCard.PinCode); var codec = new HexadecimalCodec(); int newCardId = codec.Decode(responseDocument.Data.SingleValue.Id); @@ -256,7 +279,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => cardInDatabase.OwnerName.Should().Be(newCard.OwnerName); cardInDatabase.PinCode.Should().Be(newCard.PinCode); - cardInDatabase.Account.ShouldNotBeNull(); + cardInDatabase.Account.Should().NotBeNull(); cardInDatabase.Account.Id.Should().Be(existingAccount.Id); cardInDatabase.Account.StringId.Should().Be(existingAccount.StringId); }); @@ -266,13 +289,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_update_resource_with_relationship() { // Arrange - BankAccount existingAccount = _fakers.BankAccount.Generate(); - existingAccount.Cards = _fakers.DebitCard.Generate(1); + BankAccount existingAccount = _fakers.BankAccount.GenerateOne(); + existingAccount.Cards = _fakers.DebitCard.GenerateList(1); - DebitCard existingCard = _fakers.DebitCard.Generate(); - existingCard.Account = _fakers.BankAccount.Generate(); + DebitCard existingCard = _fakers.DebitCard.GenerateOne(); + existingCard.Account = _fakers.BankAccount.GenerateOne(); - string newIban = _fakers.BankAccount.Generate().Iban; + string newIban = _fakers.BankAccount.GenerateOne().Iban; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -323,7 +346,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => accountInDatabase.Iban.Should().Be(newIban); - accountInDatabase.Cards.ShouldHaveCount(1); + accountInDatabase.Cards.Should().HaveCount(1); accountInDatabase.Cards[0].Id.Should().Be(existingCard.Id); accountInDatabase.Cards[0].StringId.Should().Be(existingCard.StringId); }); @@ -333,11 +356,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_add_to_ToMany_relationship() { // Arrange - BankAccount existingAccount = _fakers.BankAccount.Generate(); - existingAccount.Cards = _fakers.DebitCard.Generate(1); + BankAccount existingAccount = _fakers.BankAccount.GenerateOne(); + existingAccount.Cards = _fakers.DebitCard.GenerateList(1); - DebitCard existingDebitCard = _fakers.DebitCard.Generate(); - existingDebitCard.Account = _fakers.BankAccount.Generate(); + DebitCard existingDebitCard = _fakers.DebitCard.GenerateOne(); + existingDebitCard.Account = _fakers.BankAccount.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -371,7 +394,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { BankAccount accountInDatabase = await dbContext.BankAccounts.Include(account => account.Cards).FirstWithIdAsync(existingAccount.Id); - accountInDatabase.Cards.ShouldHaveCount(2); + accountInDatabase.Cards.Should().HaveCount(2); }); } @@ -379,8 +402,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_remove_from_ToMany_relationship() { // Arrange - BankAccount existingAccount = _fakers.BankAccount.Generate(); - existingAccount.Cards = _fakers.DebitCard.Generate(2); + BankAccount existingAccount = _fakers.BankAccount.GenerateOne(); + existingAccount.Cards = _fakers.DebitCard.GenerateList(2); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -414,7 +437,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { BankAccount accountInDatabase = await dbContext.BankAccounts.Include(account => account.Cards).FirstWithIdAsync(existingAccount.Id); - accountInDatabase.Cards.ShouldHaveCount(1); + accountInDatabase.Cards.Should().HaveCount(1); }); } @@ -422,8 +445,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_delete_resource() { // Arrange - BankAccount existingAccount = _fakers.BankAccount.Generate(); - existingAccount.Cards = _fakers.DebitCard.Generate(1); + BankAccount existingAccount = _fakers.BankAccount.GenerateOne(); + existingAccount.Cards = _fakers.DebitCard.GenerateList(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -464,7 +487,7 @@ public async Task Cannot_delete_unknown_resource() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiable.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiable.cs index 0aa2203d8a..af6a8a1416 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiable.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiable.cs @@ -8,11 +8,11 @@ public abstract class ObfuscatedIdentifiable : Identifiable protected override string? GetStringId(int value) { - return value == default ? null : Codec.Encode(value); + return value == 0 ? null : Codec.Encode(value); } protected override int GetTypedId(string? value) { - return value == null ? default : Codec.Decode(value); + return value == null ? 0 : Codec.Decode(value); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiableController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiableController.cs index cded455259..ffe73f7b9a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiableController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiableController.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Resources; @@ -5,84 +6,90 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; +#pragma warning disable format + namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation; -public abstract class ObfuscatedIdentifiableController : BaseJsonApiController +public abstract class ObfuscatedIdentifiableController( + IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceService resourceService) + : BaseJsonApiController(options, resourceGraph, loggerFactory, resourceService) where TResource : class, IIdentifiable { private readonly HexadecimalCodec _codec = new(); - protected ObfuscatedIdentifiableController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, resourceGraph, loggerFactory, resourceService) - { - } - [HttpGet] + [HttpHead] public override Task GetAsync(CancellationToken cancellationToken) { return base.GetAsync(cancellationToken); } [HttpGet("{id}")] - public Task GetAsync(string id, CancellationToken cancellationToken) + [HttpHead("{id}")] + public Task GetAsync([Required] string id, CancellationToken cancellationToken) { int idValue = _codec.Decode(id); return base.GetAsync(idValue, cancellationToken); } [HttpGet("{id}/{relationshipName}")] - public Task GetSecondaryAsync(string id, string relationshipName, CancellationToken cancellationToken) + [HttpHead("{id}/{relationshipName}")] + public Task GetSecondaryAsync([Required] string id, [Required] [PreserveEmptyString] string relationshipName, + CancellationToken cancellationToken) { int idValue = _codec.Decode(id); return base.GetSecondaryAsync(idValue, relationshipName, cancellationToken); } [HttpGet("{id}/relationships/{relationshipName}")] - public Task GetRelationshipAsync(string id, string relationshipName, CancellationToken cancellationToken) + [HttpHead("{id}/relationships/{relationshipName}")] + public Task GetRelationshipAsync([Required] string id, [Required] [PreserveEmptyString] string relationshipName, + CancellationToken cancellationToken) { int idValue = _codec.Decode(id); return base.GetRelationshipAsync(idValue, relationshipName, cancellationToken); } [HttpPost] - public override Task PostAsync([FromBody] TResource resource, CancellationToken cancellationToken) + public override Task PostAsync([FromBody] [Required] TResource resource, CancellationToken cancellationToken) { return base.PostAsync(resource, cancellationToken); } [HttpPost("{id}/relationships/{relationshipName}")] - public Task PostRelationshipAsync(string id, string relationshipName, [FromBody] ISet rightResourceIds, - CancellationToken cancellationToken) + public Task PostRelationshipAsync([Required] string id, [Required] [PreserveEmptyString] string relationshipName, + [FromBody] [Required] ISet rightResourceIds, CancellationToken cancellationToken) { int idValue = _codec.Decode(id); return base.PostRelationshipAsync(idValue, relationshipName, rightResourceIds, cancellationToken); } [HttpPatch("{id}")] - public Task PatchAsync(string id, [FromBody] TResource resource, CancellationToken cancellationToken) + public Task PatchAsync([Required] string id, [FromBody] [Required] TResource resource, CancellationToken cancellationToken) { int idValue = _codec.Decode(id); return base.PatchAsync(idValue, resource, cancellationToken); } [HttpPatch("{id}/relationships/{relationshipName}")] - public Task PatchRelationshipAsync(string id, string relationshipName, [FromBody] object rightValue, CancellationToken cancellationToken) + // Parameter `[Required] object? rightValue` makes Swashbuckle generate the OpenAPI request body as required. We don't actually validate ModelState, so it doesn't hurt. + public Task PatchRelationshipAsync([Required] string id, [Required] [PreserveEmptyString] string relationshipName, + [FromBody] [Required] object? rightValue, CancellationToken cancellationToken) { int idValue = _codec.Decode(id); return base.PatchRelationshipAsync(idValue, relationshipName, rightValue, cancellationToken); } [HttpDelete("{id}")] - public Task DeleteAsync(string id, CancellationToken cancellationToken) + public Task DeleteAsync([Required] string id, CancellationToken cancellationToken) { int idValue = _codec.Decode(id); return base.DeleteAsync(idValue, cancellationToken); } [HttpDelete("{id}/relationships/{relationshipName}")] - public Task DeleteRelationshipAsync(string id, string relationshipName, [FromBody] ISet rightResourceIds, - CancellationToken cancellationToken) + public Task DeleteRelationshipAsync([Required] string id, [Required] [PreserveEmptyString] string relationshipName, + [FromBody] [Required] ISet rightResourceIds, CancellationToken cancellationToken) { int idValue = _codec.Decode(id); return base.DeleteRelationshipAsync(idValue, relationshipName, rightResourceIds, cancellationToken); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscationDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscationDbContext.cs index 94921ea800..94188a7d60 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscationDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscationDbContext.cs @@ -1,16 +1,13 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; namespace JsonApiDotNetCoreTests.IntegrationTests.IdObfuscation; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class ObfuscationDbContext : DbContext +public sealed class ObfuscationDbContext(DbContextOptions options) + : TestableDbContext(options) { public DbSet BankAccounts => Set(); public DbSet DebitCards => Set(); - - public ObfuscationDbContext(DbContextOptions options) - : base(options) - { - } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscationFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/IdObfuscation/ObfuscationFakers.cs index 201089d15b..001a2cbe52 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 +internal sealed class ObfuscationFakers { - private readonly Lazy> _lazyBankAccountFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(bankAccount => bankAccount.Iban, faker => faker.Finance.Iban())); + private readonly Lazy> _lazyBankAccountFaker = new(() => new Faker() + .MakeDeterministic() + .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() + .MakeDeterministic() + .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/ModelStateDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateDbContext.cs index 2f1b56cf6f..9a480988cc 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateDbContext.cs @@ -1,22 +1,19 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; // @formatter:wrap_chained_method_calls chop_always namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.ModelState; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class ModelStateDbContext : DbContext +public sealed class ModelStateDbContext(DbContextOptions options) + : TestableDbContext(options) { public DbSet Volumes => Set(); public DbSet Directories => Set(); public DbSet Files => Set(); - public ModelStateDbContext(DbContextOptions options) - : base(options) - { - } - protected override void OnModelCreating(ModelBuilder builder) { builder.Entity() @@ -36,5 +33,7 @@ protected override void OnModelCreating(ModelBuilder builder) builder.Entity() .HasOne(systemDirectory => systemDirectory.AlsoSelf) .WithOne(); + + base.OnModelCreating(builder); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateFakers.cs index 75183eaf9f..6a11c9c247 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateFakers.cs @@ -1,30 +1,36 @@ +using System.Globalization; 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; -internal sealed class ModelStateFakers : FakerContainer +internal sealed class ModelStateFakers { - 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.SizeInBytes, faker => faker.Random.Long(0, 1_000_000))); - - private readonly Lazy> _lazySystemDirectoryFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(systemDirectory => systemDirectory.Name, faker => faker.Address.City()) - .RuleFor(systemDirectory => systemDirectory.IsCaseSensitive, faker => faker.Random.Bool()) - .RuleFor(systemDirectory => systemDirectory.SizeInBytes, faker => faker.Random.Long(0, 1_000_000))); + private static readonly DateOnly MinCreatedOn = DateOnly.Parse("2000-01-01", CultureInfo.InvariantCulture); + private static readonly DateOnly MaxCreatedOn = DateOnly.Parse("2050-01-01", CultureInfo.InvariantCulture); + + 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() + .MakeDeterministic() + .RuleFor(systemVolume => systemVolume.Name, faker => faker.Lorem.Word())); + + private readonly Lazy> _lazySystemFileFaker = new(() => new Faker() + .MakeDeterministic() + .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() + .MakeDeterministic() + .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/InputValidation/ModelState/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs index 7df5b15a74..7cedc98f5e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs @@ -43,13 +43,13 @@ public async Task Cannot_create_resource_with_omitted_required_attribute() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Input validation failed."); error.Detail.Should().Be("The Name field is required."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/data/attributes/directoryName"); } @@ -78,13 +78,13 @@ public async Task Cannot_create_resource_with_null_for_required_attribute_value( // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Input validation failed."); error.Detail.Should().Be("The Name field is required."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/data/attributes/directoryName"); } @@ -113,21 +113,68 @@ public async Task Cannot_create_resource_with_invalid_attribute_value() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Input validation failed."); - error.Detail.Should().Be("The field Name must match the regular expression '^[\\w\\s]+$'."); - error.Source.ShouldNotBeNull(); + error.Detail.Should().Be(@"The field Name must match the regular expression '^[\w\s]+$'."); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/data/attributes/directoryName"); } + [Fact] + public async Task Cannot_create_resource_with_invalid_DateOnly_TimeOnly_attribute_value() + { + // Arrange + SystemFile newFile = _fakers.SystemFile.GenerateOne(); + + var requestBody = new + { + data = new + { + type = "systemFiles", + attributes = new + { + fileName = newFile.FileName, + attributes = newFile.Attributes, + sizeInBytes = newFile.SizeInBytes, + createdOn = DateOnly.MinValue, + createdAt = TimeOnly.MinValue + } + } + }; + + const string route = "/systemFiles"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.Should().HaveCount(2); + + ErrorObject error1 = responseDocument.Errors[0]; + error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error1.Title.Should().Be("Input validation failed."); + error1.Detail.Should().StartWith("The field CreatedAt must be between "); + error1.Source.Should().NotBeNull(); + error1.Source.Pointer.Should().Be("/data/attributes/createdAt"); + + ErrorObject error2 = responseDocument.Errors[1]; + error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error2.Title.Should().Be("Input validation failed."); + error2.Detail.Should().StartWith("The field CreatedOn must be between "); + error2.Source.Should().NotBeNull(); + error2.Source.Pointer.Should().Be("/data/attributes/createdOn"); + } + [Fact] public async Task Can_create_resource_with_valid_attribute_value() { // Arrange - SystemDirectory newDirectory = _fakers.SystemDirectory.Generate(); + SystemDirectory newDirectory = _fakers.SystemDirectory.GenerateOne(); var requestBody = new { @@ -150,9 +197,9 @@ public async Task Can_create_resource_with_valid_attribute_value() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("directoryName").With(value => value.Should().Be(newDirectory.Name)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("isCaseSensitive").With(value => value.Should().Be(newDirectory.IsCaseSensitive)); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("directoryName").WhoseValue.Should().Be(newDirectory.Name); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("isCaseSensitive").WhoseValue.Should().Be(newDirectory.IsCaseSensitive); } [Fact] @@ -166,8 +213,6 @@ public async Task Cannot_create_resource_with_multiple_violations() type = "systemDirectories", attributes = new { - isCaseSensitive = false, - sizeInBytes = -1 } } }; @@ -180,21 +225,21 @@ public async Task Cannot_create_resource_with_multiple_violations() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(2); + responseDocument.Errors.Should().HaveCount(2); ErrorObject error1 = responseDocument.Errors[0]; error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error1.Title.Should().Be("Input validation failed."); error1.Detail.Should().Be("The Name field is required."); - error1.Source.ShouldNotBeNull(); + error1.Source.Should().NotBeNull(); error1.Source.Pointer.Should().Be("/data/attributes/directoryName"); ErrorObject error2 = responseDocument.Errors[1]; error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error2.Title.Should().Be("Input validation failed."); - error2.Detail.Should().Be("The field SizeInBytes must be between 0 and 9223372036854775807."); - error2.Source.ShouldNotBeNull(); - error2.Source.Pointer.Should().Be("/data/attributes/sizeInBytes"); + error2.Detail.Should().Be("The IsCaseSensitive field is required."); + error2.Source.Should().NotBeNull(); + error2.Source.Pointer.Should().Be("/data/attributes/isCaseSensitive"); } [Fact] @@ -205,15 +250,14 @@ public async Task Does_not_exceed_MaxModelValidationErrors() { data = new { - type = "systemDirectories", + type = "systemFiles", attributes = new { - sizeInBytes = -1 } } }; - const string route = "/systemDirectories"; + const string route = "/systemFiles"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -221,7 +265,7 @@ public async Task Does_not_exceed_MaxModelValidationErrors() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(3); + responseDocument.Errors.Should().HaveCount(3); ErrorObject error1 = responseDocument.Errors[0]; error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); @@ -232,27 +276,27 @@ public async Task Does_not_exceed_MaxModelValidationErrors() ErrorObject error2 = responseDocument.Errors[1]; error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error2.Title.Should().Be("Input validation failed."); - error2.Detail.Should().Be("The Name field is required."); - error2.Source.ShouldNotBeNull(); - error2.Source.Pointer.Should().Be("/data/attributes/directoryName"); + error2.Detail.Should().Be("The FileName field is required."); + error2.Source.Should().NotBeNull(); + error2.Source.Pointer.Should().Be("/data/attributes/fileName"); ErrorObject error3 = responseDocument.Errors[2]; error3.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error3.Title.Should().Be("Input validation failed."); - error3.Detail.Should().Be("The IsCaseSensitive field is required."); - error3.Source.ShouldNotBeNull(); - error3.Source.Pointer.Should().Be("/data/attributes/isCaseSensitive"); + error3.Detail.Should().Be("The Attributes field is required."); + error3.Source.Should().NotBeNull(); + error3.Source.Pointer.Should().Be("/data/attributes/attributes"); } [Fact] public async Task Can_create_resource_with_annotated_relationships() { // Arrange - SystemDirectory existingParentDirectory = _fakers.SystemDirectory.Generate(); - SystemDirectory existingSubdirectory = _fakers.SystemDirectory.Generate(); - SystemFile existingFile = _fakers.SystemFile.Generate(); + SystemDirectory existingParentDirectory = _fakers.SystemDirectory.GenerateOne(); + SystemDirectory existingSubdirectory = _fakers.SystemDirectory.GenerateOne(); + SystemFile existingFile = _fakers.SystemFile.GenerateOne(); - SystemDirectory newDirectory = _fakers.SystemDirectory.Generate(); + SystemDirectory newDirectory = _fakers.SystemDirectory.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -315,17 +359,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("directoryName").With(value => value.Should().Be(newDirectory.Name)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("isCaseSensitive").With(value => value.Should().Be(newDirectory.IsCaseSensitive)); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("directoryName").WhoseValue.Should().Be(newDirectory.Name); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("isCaseSensitive").WhoseValue.Should().Be(newDirectory.IsCaseSensitive); } [Fact] public async Task Can_add_to_annotated_ToMany_relationship() { // Arrange - SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); - SystemFile existingFile = _fakers.SystemFile.Generate(); + SystemDirectory existingDirectory = _fakers.SystemDirectory.GenerateOne(); + SystemFile existingFile = _fakers.SystemFile.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -360,13 +404,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_update_resource_with_omitted_required_attribute_value() { // Arrange - SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + SystemFile existingFile = _fakers.SystemFile.GenerateOne(); - long newSizeInBytes = _fakers.SystemDirectory.Generate().SizeInBytes; + long? newSizeInBytes = _fakers.SystemFile.GenerateOne().SizeInBytes; await _testContext.RunOnDatabaseAsync(async dbContext => { - dbContext.Directories.Add(existingDirectory); + dbContext.Files.Add(existingFile); await dbContext.SaveChangesAsync(); }); @@ -374,8 +418,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => { data = new { - type = "systemDirectories", - id = existingDirectory.StringId, + type = "systemFiles", + id = existingFile.StringId, attributes = new { sizeInBytes = newSizeInBytes @@ -383,7 +427,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string route = $"/systemDirectories/{existingDirectory.StringId}"; + string route = $"/systemFiles/{existingFile.StringId}"; // Act (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -398,7 +442,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_update_resource_with_null_for_required_attribute_values() { // Arrange - SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + SystemDirectory existingDirectory = _fakers.SystemDirectory.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -428,20 +472,20 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(2); + responseDocument.Errors.Should().HaveCount(2); ErrorObject error1 = responseDocument.Errors[0]; error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error1.Title.Should().Be("Input validation failed."); error1.Detail.Should().Be("The Name field is required."); - error1.Source.ShouldNotBeNull(); + error1.Source.Should().NotBeNull(); error1.Source.Pointer.Should().Be("/data/attributes/directoryName"); ErrorObject error2 = responseDocument.Errors[1]; error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error2.Title.Should().Be("Input validation failed."); error2.Detail.Should().Be("The IsCaseSensitive field is required."); - error2.Source.ShouldNotBeNull(); + error2.Source.Should().NotBeNull(); error2.Source.Pointer.Should().Be("/data/attributes/isCaseSensitive"); } @@ -449,7 +493,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_update_resource_with_invalid_attribute_value() { // Arrange - SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + SystemDirectory existingDirectory = _fakers.SystemDirectory.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -478,13 +522,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Input validation failed."); - error.Detail.Should().Be("The field Name must match the regular expression '^[\\w\\s]+$'."); - error.Source.ShouldNotBeNull(); + error.Detail.Should().Be(@"The field Name must match the regular expression '^[\w\s]+$'."); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/data/attributes/directoryName"); } @@ -523,20 +567,20 @@ public async Task Cannot_update_resource_with_invalid_ID() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(2); + responseDocument.Errors.Should().HaveCount(2); ErrorObject error1 = responseDocument.Errors[0]; error1.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error1.Title.Should().Be("Input validation failed."); error1.Detail.Should().Be("The field Id must match the regular expression '^[0-9]+$'."); - error1.Source.ShouldNotBeNull(); + error1.Source.Should().NotBeNull(); error1.Source.Pointer.Should().Be("/data/id"); ErrorObject error2 = responseDocument.Errors[1]; error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error2.Title.Should().Be("Input validation failed."); error2.Detail.Should().Be("The field Id must match the regular expression '^[0-9]+$'."); - error2.Source.ShouldNotBeNull(); + error2.Source.Should().NotBeNull(); error2.Source.Pointer.Should().Be("/data/relationships/subdirectories/data[0]/id"); } @@ -544,9 +588,9 @@ public async Task Cannot_update_resource_with_invalid_ID() public async Task Can_update_resource_with_valid_attribute_value() { // Arrange - SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + SystemDirectory existingDirectory = _fakers.SystemDirectory.GenerateOne(); - string newDirectoryName = _fakers.SystemDirectory.Generate().Name; + string newDirectoryName = _fakers.SystemDirectory.GenerateOne().Name; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -582,16 +626,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_update_resource_with_annotated_relationships() { // Arrange - SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); - existingDirectory.Subdirectories = _fakers.SystemDirectory.Generate(1); - existingDirectory.Files = _fakers.SystemFile.Generate(1); - existingDirectory.Parent = _fakers.SystemDirectory.Generate(); + SystemDirectory existingDirectory = _fakers.SystemDirectory.GenerateOne(); + existingDirectory.Subdirectories = _fakers.SystemDirectory.GenerateList(1); + existingDirectory.Files = _fakers.SystemFile.GenerateList(1); + existingDirectory.Parent = _fakers.SystemDirectory.GenerateOne(); - SystemDirectory existingParent = _fakers.SystemDirectory.Generate(); - SystemDirectory existingSubdirectory = _fakers.SystemDirectory.Generate(); - SystemFile existingFile = _fakers.SystemFile.Generate(); + SystemDirectory existingParent = _fakers.SystemDirectory.GenerateOne(); + SystemDirectory existingSubdirectory = _fakers.SystemDirectory.GenerateOne(); + SystemFile existingFile = _fakers.SystemFile.GenerateOne(); - string newDirectoryName = _fakers.SystemDirectory.Generate().Name; + string newDirectoryName = _fakers.SystemDirectory.GenerateOne().Name; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -661,7 +705,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_update_resource_with_multiple_self_references() { // Arrange - SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + SystemDirectory existingDirectory = _fakers.SystemDirectory.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -712,7 +756,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_update_resource_with_collection_of_self_references() { // Arrange - SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + SystemDirectory existingDirectory = _fakers.SystemDirectory.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -758,10 +802,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_replace_annotated_ToOne_relationship() { // Arrange - SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); - existingDirectory.Parent = _fakers.SystemDirectory.Generate(); + SystemDirectory existingDirectory = _fakers.SystemDirectory.GenerateOne(); + existingDirectory.Parent = _fakers.SystemDirectory.GenerateOne(); - SystemDirectory otherExistingDirectory = _fakers.SystemDirectory.Generate(); + SystemDirectory otherExistingDirectory = _fakers.SystemDirectory.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -793,10 +837,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_replace_annotated_ToMany_relationship() { // Arrange - SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); - existingDirectory.Files = _fakers.SystemFile.Generate(2); + SystemDirectory existingDirectory = _fakers.SystemDirectory.GenerateOne(); + existingDirectory.Files = _fakers.SystemFile.GenerateList(2); - SystemFile existingFile = _fakers.SystemFile.Generate(); + SystemFile existingFile = _fakers.SystemFile.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -831,8 +875,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_remove_from_annotated_ToMany_relationship() { // Arrange - SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); - existingDirectory.Files = _fakers.SystemFile.Generate(1); + SystemDirectory existingDirectory = _fakers.SystemDirectory.GenerateOne(); + existingDirectory.Files = _fakers.SystemFile.GenerateList(1); await _testContext.RunOnDatabaseAsync(async dbContext => { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs index 671123930e..4245ff2fc1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs @@ -46,15 +46,15 @@ public async Task Can_create_resource_with_invalid_attribute_value() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("directoryName").With(value => value.Should().Be("!@#$%^&*().-")); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("directoryName").WhoseValue.Should().Be("!@#$%^&*().-"); } [Fact] public async Task Can_update_resource_with_invalid_attribute_value() { // Arrange - SystemDirectory existingDirectory = _fakers.SystemDirectory.Generate(); + SystemDirectory existingDirectory = _fakers.SystemDirectory.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -87,11 +87,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_clear_required_OneToOne_relationship_through_primary_endpoint() + public async Task Cannot_clear_required_OneToOne_relationship_at_primary_endpoint() { // Arrange - SystemVolume existingVolume = _fakers.SystemVolume.Generate(); - existingVolume.RootDirectory = _fakers.SystemDirectory.Generate(); + SystemVolume existingVolume = _fakers.SystemVolume.GenerateOne(); + existingVolume.RootDirectory = _fakers.SystemDirectory.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -123,13 +123,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("Failed to clear a required relationship."); - - error.Detail.Should().Be($"The relationship 'rootDirectory' on resource type 'systemVolumes' with ID '{existingVolume.StringId}' " + - "cannot be cleared because it is a required relationship."); + error.Detail.Should().Be("The relationship 'rootDirectory' on resource type 'systemVolumes' cannot be cleared because it is a required relationship."); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemDirectory.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemDirectory.cs index 1712ad103e..4265dc688e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemDirectory.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemDirectory.cs @@ -20,10 +20,6 @@ public sealed class SystemDirectory : Identifiable [Required] public bool? IsCaseSensitive { get; set; } - [Attr] - [Range(typeof(long), "0", "9223372036854775807")] - public long SizeInBytes { get; set; } - [HasMany] public ICollection Subdirectories { get; set; } = new List(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs index 56bd50d1e7..ef90adcc79 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs @@ -15,6 +15,17 @@ public sealed class SystemFile : Identifiable [Attr] [Required] - [Range(typeof(long), "0", "9223372036854775807")] - public long? SizeInBytes { get; set; } + public FileAttributes? Attributes { get; set; } + + [Attr] + [Range(typeof(long), "1", "9223372036854775807")] + public long SizeInBytes { get; set; } + + [Attr] + [Range(typeof(DateOnly), "2000-01-01", "2050-01-01", ParseLimitsInInvariantCulture = true)] + public DateOnly CreatedOn { get; set; } + + [Attr] + [Range(typeof(TimeOnly), "09:00:00", "17:30:00", ParseLimitsInInvariantCulture = true)] + public TimeOnly CreatedAt { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDbContext.cs index decc09bdc6..e7c264ab11 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDbContext.cs @@ -1,17 +1,14 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; // @formatter:wrap_chained_method_calls chop_always namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.RequestBody; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class WorkflowDbContext : DbContext +public sealed class WorkflowDbContext(DbContextOptions options) + : TestableDbContext(options) { public DbSet Workflows => Set(); - - public WorkflowDbContext(DbContextOptions options) - : base(options) - { - } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDefinition.cs index f63b793e5b..aaa35af882 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowDefinition.cs @@ -9,35 +9,35 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.InputValidation.RequestBody; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class WorkflowDefinition : JsonApiResourceDefinition +public sealed class WorkflowDefinition(IResourceGraph resourceGraph) + : JsonApiResourceDefinition(resourceGraph) { - private static readonly Dictionary> StageTransitionTable = new() + private static readonly Dictionary StageTransitionTable = new() { - [WorkflowStage.Created] = new[] - { + // @formatter:place_simple_list_pattern_on_single_line false + + [WorkflowStage.Created] = + [ WorkflowStage.InProgress - }, - [WorkflowStage.InProgress] = new[] - { + ], + [WorkflowStage.InProgress] = + [ WorkflowStage.OnHold, WorkflowStage.Succeeded, WorkflowStage.Failed, WorkflowStage.Canceled - }, - [WorkflowStage.OnHold] = new[] - { + ], + [WorkflowStage.OnHold] = + [ WorkflowStage.InProgress, WorkflowStage.Canceled - } + ] + + // @formatter:place_simple_list_pattern_on_single_line restore }; private WorkflowStage _previousStage; - public WorkflowDefinition(IResourceGraph resourceGraph) - : base(resourceGraph) - { - } - public override Task OnPrepareWriteAsync(Workflow resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) { if (writeOperation == WriteOperationKind.UpdateResource) @@ -98,7 +98,7 @@ private static void AssertCanTransitionToStage(WorkflowStage fromStage, Workflow private static bool CanTransitionToStage(WorkflowStage fromStage, WorkflowStage toStage) { - if (StageTransitionTable.TryGetValue(fromStage, out ICollection? possibleNextStages)) + if (StageTransitionTable.TryGetValue(fromStage, out WorkflowStage[]? possibleNextStages)) { return possibleNextStages.Contains(toStage); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs index 4653118ab6..9b8fd9cbc9 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs @@ -17,10 +17,7 @@ public WorkflowTests(IntegrationTestContext, testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddResourceDefinition(); - }); + testContext.ConfigureServices(services => services.AddResourceDefinition()); } [Fact] @@ -47,7 +44,7 @@ public async Task Can_create_in_valid_stage() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); } [Fact] @@ -74,13 +71,13 @@ public async Task Cannot_create_in_invalid_stage() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Invalid workflow stage."); error.Detail.Should().Be("Initial stage of workflow must be 'Created'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/data/attributes/stage"); } @@ -120,13 +117,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Invalid workflow stage."); error.Detail.Should().Be("Cannot transition from 'OnHold' to 'Succeeded'."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/data/attributes/stage"); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs index 62ba148dc4..688ef470b8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs @@ -25,10 +25,7 @@ public AbsoluteLinksWithNamespaceTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); + testContext.ConfigureServices(services => services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>))); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); options.IncludeTotalResourceCount = true; @@ -38,7 +35,7 @@ public AbsoluteLinksWithNamespaceTests(IntegrationTestContext { @@ -54,22 +51,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Links.Should().NotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + responseDocument.Data.SingleValue.Relationships.Should().ContainKey("photos").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{HostPrefix}{route}/relationships/photos"); value.Links.Related.Should().Be($"{HostPrefix}{route}/photos"); }); @@ -79,8 +77,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Get_primary_resources_with_include_returns_absolute_links() { // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + PhotoAlbum album = _fakers.PhotoAlbum.GenerateOne(); + album.Photos = _fakers.Photo.GenerateSet(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -97,45 +95,46 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().Be(responseDocument.Links.Self); responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].With(resource => { string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}"; - resource.Links.ShouldNotBeNull(); + resource.Links.Should().NotBeNull(); resource.Links.Self.Should().Be(albumLink); - resource.Relationships.ShouldContainKey("photos").With(value => + resource.Relationships.Should().ContainKey("photos").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); value.Links.Related.Should().Be($"{albumLink}/photos"); }); }); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].With(resource => { string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; - resource.Links.ShouldNotBeNull(); + resource.Links.Should().NotBeNull(); resource.Links.Self.Should().Be(photoLink); - resource.Relationships.ShouldContainKey("album").With(value => + resource.Relationships.Should().ContainKey("album").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{photoLink}/relationships/album"); value.Links.Related.Should().Be($"{photoLink}/album"); }); @@ -146,8 +145,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Get_secondary_resource_returns_absolute_links() { // Arrange - Photo photo = _fakers.Photo.Generate(); - photo.Album = _fakers.PhotoAlbum.Generate(); + Photo photo = _fakers.Photo.GenerateOne(); + photo.Album = _fakers.PhotoAlbum.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -163,24 +162,25 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{photo.Album.StringId}"; - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Links.Should().NotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + responseDocument.Data.SingleValue.Relationships.Should().ContainKey("photos").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); value.Links.Related.Should().Be($"{albumLink}/photos"); }); @@ -190,8 +190,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Get_secondary_resources_returns_absolute_links() { // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + PhotoAlbum album = _fakers.PhotoAlbum.GenerateOne(); + album.Photos = _fakers.Photo.GenerateSet(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -207,27 +207,28 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().Be(responseDocument.Links.Self); responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].With(resource => { string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; - resource.Links.ShouldNotBeNull(); + resource.Links.Should().NotBeNull(); resource.Links.Self.Should().Be(photoLink); - resource.Relationships.ShouldContainKey("album").With(value => + resource.Relationships.Should().ContainKey("album").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{photoLink}/relationships/album"); value.Links.Related.Should().Be($"{photoLink}/album"); }); @@ -238,8 +239,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Get_ToOne_relationship_returns_absolute_links() { // Arrange - Photo photo = _fakers.Photo.Generate(); - photo.Album = _fakers.PhotoAlbum.Generate(); + Photo photo = _fakers.Photo.GenerateOne(); + photo.Album = _fakers.PhotoAlbum.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -255,15 +256,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photos/{photo.StringId}/album"); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Links.Should().BeNull(); responseDocument.Data.SingleValue.Relationships.Should().BeNull(); } @@ -272,8 +274,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Get_ToMany_relationship_returns_absolute_links() { // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + PhotoAlbum album = _fakers.PhotoAlbum.GenerateOne(); + album.Photos = _fakers.Photo.GenerateSet(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -289,15 +291,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}/photos"); responseDocument.Links.First.Should().Be(responseDocument.Links.Self); responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Links.Should().BeNull(); responseDocument.Data.ManyValue[0].Relationships.Should().BeNull(); } @@ -306,9 +309,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Create_resource_with_side_effects_and_include_returns_absolute_links() { // Arrange - Photo existingPhoto = _fakers.Photo.Generate(); + Photo existingPhoto = _fakers.Photo.GenerateOne(); - string newAlbumName = _fakers.PhotoAlbum.Generate().Name; + string newAlbumName = _fakers.PhotoAlbum.GenerateOne().Name; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -350,54 +353,57 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Links.Should().NotBeNull(); string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{responseDocument.Data.SingleValue.Id}"; responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + responseDocument.Data.SingleValue.Relationships.Should().ContainKey("photos").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); value.Links.Related.Should().Be($"{albumLink}/photos"); }); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].With(resource => { string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; - resource.Links.ShouldNotBeNull(); + resource.Links.Should().NotBeNull(); resource.Links.Self.Should().Be(photoLink); - resource.Relationships.ShouldContainKey("album").With(value => + resource.Relationships.Should().ContainKey("album").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{photoLink}/relationships/album"); value.Links.Related.Should().Be($"{photoLink}/album"); }); }); + + httpResponse.Headers.Location.Should().Be(albumLink); } [Fact] public async Task Update_resource_with_side_effects_and_include_returns_absolute_links() { // Arrange - Photo existingPhoto = _fakers.Photo.Generate(); - PhotoAlbum existingAlbum = _fakers.PhotoAlbum.Generate(); + Photo existingPhoto = _fakers.Photo.GenerateOne(); + PhotoAlbum existingAlbum = _fakers.PhotoAlbum.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -433,41 +439,42 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Links.Should().NotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be(photoLink); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("album").With(value => + responseDocument.Data.SingleValue.Relationships.Should().ContainKey("album").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{photoLink}/relationships/album"); value.Links.Related.Should().Be($"{photoLink}/album"); }); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].With(resource => { string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{existingAlbum.StringId}"; - resource.Links.ShouldNotBeNull(); + resource.Links.Should().NotBeNull(); resource.Links.Self.Should().Be(albumLink); - resource.Relationships.ShouldContainKey("photos").With(value => + resource.Relationships.Should().ContainKey("photos").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); value.Links.Related.Should().Be($"{albumLink}/photos"); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs index 0d496f18eb..5098e9d34f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs @@ -25,10 +25,7 @@ public AbsoluteLinksWithoutNamespaceTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); + testContext.ConfigureServices(services => services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>))); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); options.IncludeTotalResourceCount = true; @@ -38,7 +35,7 @@ public AbsoluteLinksWithoutNamespaceTests(IntegrationTestContext { @@ -54,22 +51,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Links.Should().NotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + responseDocument.Data.SingleValue.Relationships.Should().ContainKey("photos").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{HostPrefix}{route}/relationships/photos"); value.Links.Related.Should().Be($"{HostPrefix}{route}/photos"); }); @@ -79,8 +77,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Get_primary_resources_with_include_returns_absolute_links() { // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + PhotoAlbum album = _fakers.PhotoAlbum.GenerateOne(); + album.Photos = _fakers.Photo.GenerateSet(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -97,45 +95,46 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().Be(responseDocument.Links.Self); responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].With(resource => { string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}"; - resource.Links.ShouldNotBeNull(); + resource.Links.Should().NotBeNull(); resource.Links.Self.Should().Be(albumLink); - resource.Relationships.ShouldContainKey("photos").With(value => + resource.Relationships.Should().ContainKey("photos").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); value.Links.Related.Should().Be($"{albumLink}/photos"); }); }); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].With(resource => { string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; - resource.Links.ShouldNotBeNull(); + resource.Links.Should().NotBeNull(); resource.Links.Self.Should().Be(photoLink); - resource.Relationships.ShouldContainKey("album").With(value => + resource.Relationships.Should().ContainKey("album").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{photoLink}/relationships/album"); value.Links.Related.Should().Be($"{photoLink}/album"); }); @@ -146,8 +145,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Get_secondary_resource_returns_absolute_links() { // Arrange - Photo photo = _fakers.Photo.Generate(); - photo.Album = _fakers.PhotoAlbum.Generate(); + Photo photo = _fakers.Photo.GenerateOne(); + photo.Album = _fakers.PhotoAlbum.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -163,24 +162,25 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{photo.Album.StringId}"; - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Links.Should().NotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + responseDocument.Data.SingleValue.Relationships.Should().ContainKey("photos").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); value.Links.Related.Should().Be($"{albumLink}/photos"); }); @@ -190,8 +190,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Get_secondary_resources_returns_absolute_links() { // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + PhotoAlbum album = _fakers.PhotoAlbum.GenerateOne(); + album.Photos = _fakers.Photo.GenerateSet(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -207,27 +207,28 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().Be(responseDocument.Links.Self); responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].With(resource => { string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; - resource.Links.ShouldNotBeNull(); + resource.Links.Should().NotBeNull(); resource.Links.Self.Should().Be(photoLink); - resource.Relationships.ShouldContainKey("album").With(value => + resource.Relationships.Should().ContainKey("album").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{photoLink}/relationships/album"); value.Links.Related.Should().Be($"{photoLink}/album"); }); @@ -238,8 +239,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Get_ToOne_relationship_returns_absolute_links() { // Arrange - Photo photo = _fakers.Photo.Generate(); - photo.Album = _fakers.PhotoAlbum.Generate(); + Photo photo = _fakers.Photo.GenerateOne(); + photo.Album = _fakers.PhotoAlbum.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -255,15 +256,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photos/{photo.StringId}/album"); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Links.Should().BeNull(); responseDocument.Data.SingleValue.Relationships.Should().BeNull(); } @@ -272,8 +274,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Get_ToMany_relationship_returns_absolute_links() { // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + PhotoAlbum album = _fakers.PhotoAlbum.GenerateOne(); + album.Photos = _fakers.Photo.GenerateSet(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -289,15 +291,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}/photos"); responseDocument.Links.First.Should().Be(responseDocument.Links.Self); responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Links.Should().BeNull(); responseDocument.Data.ManyValue[0].Relationships.Should().BeNull(); } @@ -306,9 +309,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Create_resource_with_side_effects_and_include_returns_absolute_links() { // Arrange - Photo existingPhoto = _fakers.Photo.Generate(); + Photo existingPhoto = _fakers.Photo.GenerateOne(); - string newAlbumName = _fakers.PhotoAlbum.Generate().Name; + string newAlbumName = _fakers.PhotoAlbum.GenerateOne().Name; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -350,54 +353,57 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Links.Should().NotBeNull(); string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{responseDocument.Data.SingleValue.Id}"; responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + responseDocument.Data.SingleValue.Relationships.Should().ContainKey("photos").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); value.Links.Related.Should().Be($"{albumLink}/photos"); }); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].With(resource => { string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; - resource.Links.ShouldNotBeNull(); + resource.Links.Should().NotBeNull(); resource.Links.Self.Should().Be(photoLink); - resource.Relationships.ShouldContainKey("album").With(value => + resource.Relationships.Should().ContainKey("album").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{photoLink}/relationships/album"); value.Links.Related.Should().Be($"{photoLink}/album"); }); }); + + httpResponse.Headers.Location.Should().Be(albumLink); } [Fact] public async Task Update_resource_with_side_effects_and_include_returns_absolute_links() { // Arrange - Photo existingPhoto = _fakers.Photo.Generate(); - PhotoAlbum existingAlbum = _fakers.PhotoAlbum.Generate(); + Photo existingPhoto = _fakers.Photo.GenerateOne(); + PhotoAlbum existingAlbum = _fakers.PhotoAlbum.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -433,41 +439,42 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Links.Should().NotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be(photoLink); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("album").With(value => + responseDocument.Data.SingleValue.Relationships.Should().ContainKey("album").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{photoLink}/relationships/album"); value.Links.Related.Should().Be($"{photoLink}/album"); }); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].With(resource => { string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{existingAlbum.StringId}"; - resource.Links.ShouldNotBeNull(); + resource.Links.Should().NotBeNull(); resource.Links.Self.Should().Be(albumLink); - resource.Relationships.ShouldContainKey("photos").With(value => + resource.Relationships.Should().ContainKey("photos").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); value.Links.Related.Should().Be($"{albumLink}/photos"); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/DocumentDescriptionLinkTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/DocumentDescriptionLinkTests.cs new file mode 100644 index 0000000000..99da94cb2c --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/DocumentDescriptionLinkTests.cs @@ -0,0 +1,100 @@ +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Serialization.Response; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Links; + +public sealed class DocumentDescriptionLinkTests : IClassFixture, LinksDbContext>> +{ + private readonly IntegrationTestContext, LinksDbContext> _testContext; + + public DocumentDescriptionLinkTests(IntegrationTestContext, LinksDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + + testContext.ConfigureServices(services => services.AddSingleton()); + } + + [Fact] + public async Task Get_primary_resource_by_ID_converts_relative_documentation_link_to_absolute() + { + // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.UseRelativeLinks = false; + + var provider = (TestDocumentDescriptionLinkProvider)_testContext.Factory.Services.GetRequiredService(); + provider.Link = "description/json-schema?version=v1.0"; + + string route = $"/photos/{Unknown.StringId.For()}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Links.Should().NotBeNull(); + responseDocument.Links.DescribedBy.Should().Be("http://localhost/description/json-schema?version=v1.0"); + } + + [Fact] + public async Task Get_primary_resource_by_ID_converts_absolute_documentation_link_to_relative() + { + // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.UseRelativeLinks = true; + + var provider = (TestDocumentDescriptionLinkProvider)_testContext.Factory.Services.GetRequiredService(); + provider.Link = "http://localhost:80/description/json-schema?version=v1.0"; + + string route = $"/photos/{Unknown.StringId.For()}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Links.Should().NotBeNull(); + responseDocument.Links.DescribedBy.Should().Be("description/json-schema?version=v1.0"); + } + + [Fact] + public async Task Get_primary_resource_by_ID_cannot_convert_absolute_documentation_link_to_relative() + { + // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.UseRelativeLinks = true; + + var provider = (TestDocumentDescriptionLinkProvider)_testContext.Factory.Services.GetRequiredService(); + provider.Link = "https://docs.api.com/description/json-schema?version=v1.0"; + + string route = $"/photos/{Unknown.StringId.For()}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Links.Should().NotBeNull(); + responseDocument.Links.DescribedBy.Should().Be("https://docs.api.com/description/json-schema?version=v1.0"); + } + + private sealed class TestDocumentDescriptionLinkProvider : IDocumentDescriptionLinkProvider + { + public string? Link { get; set; } + + public string? GetUrl() + { + return Link; + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinkInclusionIncludeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinkInclusionIncludeTests.cs index 6b11fb5403..bfaac74209 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinkInclusionIncludeTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinkInclusionIncludeTests.cs @@ -22,9 +22,9 @@ public LinkInclusionIncludeTests(IntegrationTestContext { @@ -40,15 +40,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photo").With(value => + responseDocument.Data.SingleValue.Relationships.Should().ContainKey("photo").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); }); - responseDocument.Included.ShouldHaveCount(2); + responseDocument.Included.Should().HaveCount(2); responseDocument.Included.Should().ContainSingle(resource => resource.Type == "photos").Subject.With(resource => { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinkInclusionTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinkInclusionTests.cs index 1405d648f2..32fecc526c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinkInclusionTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinkInclusionTests.cs @@ -24,9 +24,9 @@ public LinkInclusionTests(IntegrationTestContext public async Task Get_primary_resource_with_include_applies_links_visibility_from_ResourceLinksAttribute() { // Arrange - PhotoLocation location = _fakers.PhotoLocation.Generate(); - location.Photo = _fakers.Photo.Generate(); - location.Album = _fakers.PhotoAlbum.Generate(); + PhotoLocation location = _fakers.PhotoLocation.GenerateOne(); + location.Photo = _fakers.Photo.GenerateOne(); + location.Album = _fakers.PhotoAlbum.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -44,50 +44,50 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Should().BeNull(); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Links.Should().BeNull(); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photo").With(value => + responseDocument.Data.SingleValue.Relationships.Should().ContainKey("photo").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().BeNull(); - value.Links.Related.ShouldNotBeNull(); + value.Links.Related.Should().NotBeNull(); }); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("album").With(value => + responseDocument.Data.SingleValue.Relationships.Should().ContainKey("album").WhoseValue.With(value => { - value.ShouldNotBeNull(); + value.Should().NotBeNull(); value.Links.Should().BeNull(); }); - responseDocument.Included.ShouldHaveCount(2); + responseDocument.Included.Should().HaveCount(2); responseDocument.Included[0].With(resource => { - resource.Links.ShouldNotBeNull(); - resource.Links.Self.ShouldNotBeNull(); + resource.Links.Should().NotBeNull(); + resource.Links.Self.Should().NotBeNull(); - resource.Relationships.ShouldContainKey("location").With(value => + resource.Relationships.Should().ContainKey("location").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.ShouldNotBeNull(); - value.Links.Related.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); + value.Links.Self.Should().NotBeNull(); + value.Links.Related.Should().NotBeNull(); }); }); responseDocument.Included[1].With(resource => { - resource.Links.ShouldNotBeNull(); - resource.Links.Self.ShouldNotBeNull(); + resource.Links.Should().NotBeNull(); + resource.Links.Self.Should().NotBeNull(); - resource.Relationships.ShouldContainKey("photos").With(value => + resource.Relationships.Should().ContainKey("photos").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); - value.Links.Self.ShouldNotBeNull(); - value.Links.Related.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); + value.Links.Self.Should().NotBeNull(); + value.Links.Related.Should().NotBeNull(); }); }); } @@ -96,8 +96,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Get_secondary_resource_applies_links_visibility_from_ResourceLinksAttribute() { // Arrange - Photo photo = _fakers.Photo.Generate(); - photo.Location = _fakers.PhotoLocation.Generate(); + Photo photo = _fakers.Photo.GenerateOne(); + photo.Location = _fakers.PhotoLocation.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -115,15 +115,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Should().BeNull(); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Links.Should().BeNull(); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photo").With(value => + responseDocument.Data.SingleValue.Relationships.Should().ContainKey("photo").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().BeNull(); - value.Links.Related.ShouldNotBeNull(); + value.Links.Related.Should().NotBeNull(); }); responseDocument.Data.SingleValue.Relationships.Should().NotContainKey("album"); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinksDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinksDbContext.cs index 390f8ec5e2..4e1cbb29a4 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinksDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinksDbContext.cs @@ -1,27 +1,26 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; // @formatter:wrap_chained_method_calls chop_always namespace JsonApiDotNetCoreTests.IntegrationTests.Links; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class LinksDbContext : DbContext +public sealed class LinksDbContext(DbContextOptions options) + : TestableDbContext(options) { public DbSet PhotoAlbums => Set(); public DbSet Photos => Set(); public DbSet PhotoLocations => Set(); - public LinksDbContext(DbContextOptions options) - : base(options) - { - } - protected override void OnModelCreating(ModelBuilder builder) { builder.Entity() .HasOne(photo => photo.Location) .WithOne(location => location.Photo) .HasForeignKey("LocationId"); + + base.OnModelCreating(builder); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinksFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/LinksFakers.cs index bd34d85cf5..4b7da39862 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 +internal sealed class LinksFakers { - private readonly Lazy> _lazyPhotoAlbumFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(photoAlbum => photoAlbum.Name, faker => faker.Lorem.Sentence())); + private readonly Lazy> _lazyPhotoAlbumFaker = new(() => new Faker() + .MakeDeterministic() + .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() + .MakeDeterministic() + .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() + .MakeDeterministic() + .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/Links/RelativeLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs index a51f522b82..60471032e6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs @@ -25,10 +25,7 @@ public RelativeLinksWithNamespaceTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); + testContext.ConfigureServices(services => services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>))); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); options.IncludeTotalResourceCount = true; @@ -38,7 +35,7 @@ public RelativeLinksWithNamespaceTests(IntegrationTestContext { @@ -54,22 +51,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Links.Should().NotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + responseDocument.Data.SingleValue.Relationships.Should().ContainKey("photos").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{HostPrefix}{route}/relationships/photos"); value.Links.Related.Should().Be($"{HostPrefix}{route}/photos"); }); @@ -79,8 +77,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Get_primary_resources_with_include_returns_relative_links() { // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + PhotoAlbum album = _fakers.PhotoAlbum.GenerateOne(); + album.Photos = _fakers.Photo.GenerateSet(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -97,45 +95,46 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().Be(responseDocument.Links.Self); responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].With(resource => { string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}"; - resource.Links.ShouldNotBeNull(); + resource.Links.Should().NotBeNull(); resource.Links.Self.Should().Be(albumLink); - resource.Relationships.ShouldContainKey("photos").With(value => + resource.Relationships.Should().ContainKey("photos").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); value.Links.Related.Should().Be($"{albumLink}/photos"); }); }); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].With(resource => { string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; - resource.Links.ShouldNotBeNull(); + resource.Links.Should().NotBeNull(); resource.Links.Self.Should().Be(photoLink); - resource.Relationships.ShouldContainKey("album").With(value => + resource.Relationships.Should().ContainKey("album").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{photoLink}/relationships/album"); value.Links.Related.Should().Be($"{photoLink}/album"); }); @@ -146,8 +145,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Get_secondary_resource_returns_relative_links() { // Arrange - Photo photo = _fakers.Photo.Generate(); - photo.Album = _fakers.PhotoAlbum.Generate(); + Photo photo = _fakers.Photo.GenerateOne(); + photo.Album = _fakers.PhotoAlbum.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -163,24 +162,25 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{photo.Album.StringId}"; - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Links.Should().NotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + responseDocument.Data.SingleValue.Relationships.Should().ContainKey("photos").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); value.Links.Related.Should().Be($"{albumLink}/photos"); }); @@ -190,8 +190,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Get_secondary_resources_returns_relative_links() { // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + PhotoAlbum album = _fakers.PhotoAlbum.GenerateOne(); + album.Photos = _fakers.Photo.GenerateSet(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -207,27 +207,28 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().Be(responseDocument.Links.Self); responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].With(resource => { string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; - resource.Links.ShouldNotBeNull(); + resource.Links.Should().NotBeNull(); resource.Links.Self.Should().Be(photoLink); - resource.Relationships.ShouldContainKey("album").With(value => + resource.Relationships.Should().ContainKey("album").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{photoLink}/relationships/album"); value.Links.Related.Should().Be($"{photoLink}/album"); }); @@ -238,8 +239,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Get_ToOne_relationship_returns_relative_links() { // Arrange - Photo photo = _fakers.Photo.Generate(); - photo.Album = _fakers.PhotoAlbum.Generate(); + Photo photo = _fakers.Photo.GenerateOne(); + photo.Album = _fakers.PhotoAlbum.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -255,15 +256,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photos/{photo.StringId}/album"); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Links.Should().BeNull(); responseDocument.Data.SingleValue.Relationships.Should().BeNull(); } @@ -272,8 +274,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Get_ToMany_relationship_returns_relative_links() { // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + PhotoAlbum album = _fakers.PhotoAlbum.GenerateOne(); + album.Photos = _fakers.Photo.GenerateSet(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -289,15 +291,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}/photos"); responseDocument.Links.First.Should().Be(responseDocument.Links.Self); responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Links.Should().BeNull(); responseDocument.Data.ManyValue[0].Relationships.Should().BeNull(); } @@ -306,9 +309,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Create_resource_with_side_effects_and_include_returns_relative_links() { // Arrange - Photo existingPhoto = _fakers.Photo.Generate(); + Photo existingPhoto = _fakers.Photo.GenerateOne(); - string newAlbumName = _fakers.PhotoAlbum.Generate().Name; + string newAlbumName = _fakers.PhotoAlbum.GenerateOne().Name; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -350,54 +353,57 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Links.Should().NotBeNull(); string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{responseDocument.Data.SingleValue.Id}"; responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + responseDocument.Data.SingleValue.Relationships.Should().ContainKey("photos").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); value.Links.Related.Should().Be($"{albumLink}/photos"); }); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].With(resource => { string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; - resource.Links.ShouldNotBeNull(); + resource.Links.Should().NotBeNull(); resource.Links.Self.Should().Be(photoLink); - resource.Relationships.ShouldContainKey("album").With(value => + resource.Relationships.Should().ContainKey("album").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{photoLink}/relationships/album"); value.Links.Related.Should().Be($"{photoLink}/album"); }); }); + + httpResponse.Headers.Location.Should().Be(albumLink); } [Fact] public async Task Update_resource_with_side_effects_and_include_returns_relative_links() { // Arrange - Photo existingPhoto = _fakers.Photo.Generate(); - PhotoAlbum existingAlbum = _fakers.PhotoAlbum.Generate(); + Photo existingPhoto = _fakers.Photo.GenerateOne(); + PhotoAlbum existingAlbum = _fakers.PhotoAlbum.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -433,41 +439,42 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Links.Should().NotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be(photoLink); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("album").With(value => + responseDocument.Data.SingleValue.Relationships.Should().ContainKey("album").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{photoLink}/relationships/album"); value.Links.Related.Should().Be($"{photoLink}/album"); }); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].With(resource => { string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{existingAlbum.StringId}"; - resource.Links.ShouldNotBeNull(); + resource.Links.Should().NotBeNull(); resource.Links.Self.Should().Be(albumLink); - resource.Relationships.ShouldContainKey("photos").With(value => + resource.Relationships.Should().ContainKey("photos").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); value.Links.Related.Should().Be($"{albumLink}/photos"); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs index cf614fc8f8..bbb95668d8 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs @@ -25,10 +25,7 @@ public RelativeLinksWithoutNamespaceTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); + testContext.ConfigureServices(services => services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>))); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); options.IncludeTotalResourceCount = true; @@ -38,7 +35,7 @@ public RelativeLinksWithoutNamespaceTests(IntegrationTestContext { @@ -54,22 +51,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Links.Should().NotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + responseDocument.Data.SingleValue.Relationships.Should().ContainKey("photos").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{HostPrefix}{route}/relationships/photos"); value.Links.Related.Should().Be($"{HostPrefix}{route}/photos"); }); @@ -79,8 +77,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Get_primary_resources_with_include_returns_relative_links() { // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + PhotoAlbum album = _fakers.PhotoAlbum.GenerateOne(); + album.Photos = _fakers.Photo.GenerateSet(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -97,45 +95,46 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().Be(responseDocument.Links.Self); responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].With(resource => { string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}"; - resource.Links.ShouldNotBeNull(); + resource.Links.Should().NotBeNull(); resource.Links.Self.Should().Be(albumLink); - resource.Relationships.ShouldContainKey("photos").With(value => + resource.Relationships.Should().ContainKey("photos").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); value.Links.Related.Should().Be($"{albumLink}/photos"); }); }); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].With(resource => { string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; - resource.Links.ShouldNotBeNull(); + resource.Links.Should().NotBeNull(); resource.Links.Self.Should().Be(photoLink); - resource.Relationships.ShouldContainKey("album").With(value => + resource.Relationships.Should().ContainKey("album").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{photoLink}/relationships/album"); value.Links.Related.Should().Be($"{photoLink}/album"); }); @@ -146,8 +145,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Get_secondary_resource_returns_relative_links() { // Arrange - Photo photo = _fakers.Photo.Generate(); - photo.Album = _fakers.PhotoAlbum.Generate(); + Photo photo = _fakers.Photo.GenerateOne(); + photo.Album = _fakers.PhotoAlbum.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -163,24 +162,25 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{photo.Album.StringId}"; - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Links.Should().NotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + responseDocument.Data.SingleValue.Relationships.Should().ContainKey("photos").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); value.Links.Related.Should().Be($"{albumLink}/photos"); }); @@ -190,8 +190,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Get_secondary_resources_returns_relative_links() { // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + PhotoAlbum album = _fakers.PhotoAlbum.GenerateOne(); + album.Photos = _fakers.Photo.GenerateSet(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -207,27 +207,28 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().Be(responseDocument.Links.Self); responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].With(resource => { string photoLink = $"{HostPrefix}{PathPrefix}/photos/{album.Photos.ElementAt(0).StringId}"; - resource.Links.ShouldNotBeNull(); + resource.Links.Should().NotBeNull(); resource.Links.Self.Should().Be(photoLink); - resource.Relationships.ShouldContainKey("album").With(value => + resource.Relationships.Should().ContainKey("album").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{photoLink}/relationships/album"); value.Links.Related.Should().Be($"{photoLink}/album"); }); @@ -238,8 +239,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Get_ToOne_relationship_returns_relative_links() { // Arrange - Photo photo = _fakers.Photo.Generate(); - photo.Album = _fakers.PhotoAlbum.Generate(); + Photo photo = _fakers.Photo.GenerateOne(); + photo.Album = _fakers.PhotoAlbum.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -255,15 +256,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photos/{photo.StringId}/album"); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Links.Should().BeNull(); responseDocument.Data.SingleValue.Relationships.Should().BeNull(); } @@ -272,8 +274,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Get_ToMany_relationship_returns_relative_links() { // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); + PhotoAlbum album = _fakers.PhotoAlbum.GenerateOne(); + album.Photos = _fakers.Photo.GenerateSet(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -289,15 +291,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().Be($"{HostPrefix}{PathPrefix}/photoAlbums/{album.StringId}/photos"); responseDocument.Links.First.Should().Be(responseDocument.Links.Self); responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Links.Should().BeNull(); responseDocument.Data.ManyValue[0].Relationships.Should().BeNull(); } @@ -306,9 +309,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Create_resource_with_side_effects_and_include_returns_relative_links() { // Arrange - Photo existingPhoto = _fakers.Photo.Generate(); + Photo existingPhoto = _fakers.Photo.GenerateOne(); - string newAlbumName = _fakers.PhotoAlbum.Generate().Name; + string newAlbumName = _fakers.PhotoAlbum.GenerateOne().Name; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -350,54 +353,57 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Links.Should().NotBeNull(); string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{responseDocument.Data.SingleValue.Id}"; responseDocument.Data.SingleValue.Links.Self.Should().Be(albumLink); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("photos").With(value => + responseDocument.Data.SingleValue.Relationships.Should().ContainKey("photos").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); value.Links.Related.Should().Be($"{albumLink}/photos"); }); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].With(resource => { string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; - resource.Links.ShouldNotBeNull(); + resource.Links.Should().NotBeNull(); resource.Links.Self.Should().Be(photoLink); - resource.Relationships.ShouldContainKey("album").With(value => + resource.Relationships.Should().ContainKey("album").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{photoLink}/relationships/album"); value.Links.Related.Should().Be($"{photoLink}/album"); }); }); + + httpResponse.Headers.Location.Should().Be(albumLink); } [Fact] public async Task Update_resource_with_side_effects_and_include_returns_relative_links() { // Arrange - Photo existingPhoto = _fakers.Photo.Generate(); - PhotoAlbum existingAlbum = _fakers.PhotoAlbum.Generate(); + Photo existingPhoto = _fakers.Photo.GenerateOne(); + PhotoAlbum existingAlbum = _fakers.PhotoAlbum.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -433,41 +439,42 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Links.Should().NotBeNull(); responseDocument.Data.SingleValue.Links.Self.Should().Be(photoLink); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("album").With(value => + responseDocument.Data.SingleValue.Relationships.Should().ContainKey("album").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{photoLink}/relationships/album"); value.Links.Related.Should().Be($"{photoLink}/album"); }); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].With(resource => { string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{existingAlbum.StringId}"; - resource.Links.ShouldNotBeNull(); + resource.Links.Should().NotBeNull(); resource.Links.Self.Should().Be(albumLink); - resource.Relationships.ShouldContainKey("photos").With(value => + resource.Relationships.Should().ContainKey("photos").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{albumLink}/relationships/photos"); value.Links.Related.Should().Be($"{albumLink}/photos"); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/Banana.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/Banana.cs new file mode 100644 index 0000000000..6b1ad732d0 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/Banana.cs @@ -0,0 +1,14 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Logging; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Logging")] +public sealed class Banana : Fruit +{ + public override string Color => "Yellow"; + + [Attr] + public double LengthInCentimeters { get; set; } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/Fruit.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/Fruit.cs new file mode 100644 index 0000000000..4e26c558ff --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/Fruit.cs @@ -0,0 +1,16 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Logging; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Logging")] +public abstract class Fruit : Identifiable +{ + [Attr] + public abstract string Color { get; } + + [Attr] + public double WeightInKilograms { get; set; } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/FruitBowl.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/FruitBowl.cs new file mode 100644 index 0000000000..15cae39fe9 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/FruitBowl.cs @@ -0,0 +1,13 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Logging; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Logging")] +public sealed class FruitBowl : Identifiable +{ + [HasMany] + public ISet Fruits { get; set; } = new HashSet(); +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingDbContext.cs index 39c497616c..63a58d20b5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingDbContext.cs @@ -1,15 +1,16 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; namespace JsonApiDotNetCoreTests.IntegrationTests.Logging; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class LoggingDbContext : DbContext +public sealed class LoggingDbContext(DbContextOptions options) + : TestableDbContext(options) { public DbSet AuditEntries => Set(); - - public LoggingDbContext(DbContextOptions options) - : base(options) - { - } + public DbSet FruitBowls => Set(); + public DbSet Fruits => Set(); + public DbSet Bananas => Set(); + public DbSet Peaches => Set(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingFakers.cs index 1c467dc782..aa34ff32f5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingFakers.cs @@ -1,19 +1,29 @@ 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 +internal sealed class LoggingFakers { - 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() + .MakeDeterministic() + .RuleFor(auditEntry => auditEntry.UserName, faker => faker.Internet.UserName()) + .RuleFor(auditEntry => auditEntry.CreatedAt, faker => faker.Date.PastOffset().TruncateToWholeMilliseconds())); + + private readonly Lazy> _lazyBananaFaker = new(() => new Faker() + .MakeDeterministic() + .RuleFor(banana => banana.WeightInKilograms, faker => faker.Random.Double(.2, .3)) + .RuleFor(banana => banana.LengthInCentimeters, faker => faker.Random.Double(10, 25))); + + private readonly Lazy> _lazyPeachFaker = new(() => new Faker() + .MakeDeterministic() + .RuleFor(peach => peach.WeightInKilograms, faker => faker.Random.Double(.2, .3)) + .RuleFor(peach => peach.DiameterInCentimeters, faker => faker.Random.Double(6, 7.5))); public Faker AuditEntry => _lazyAuditEntryFaker.Value; + public Faker Banana => _lazyBananaFaker.Value; + public Faker Peach => _lazyPeachFaker.Value; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs index d297467bc4..4d35403f9e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/LoggingTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Net; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; @@ -17,19 +18,17 @@ public LoggingTests(IntegrationTestContext, Lo _testContext = testContext; testContext.UseController(); - - var loggerFactory = new FakeLoggerFactory(LogLevel.Trace); + testContext.UseController(); testContext.ConfigureLogging(options => { - options.ClearProviders(); - options.AddProvider(loggerFactory); + var loggerProvider = new CapturingLoggerProvider((category, level) => + level >= LogLevel.Trace && category.StartsWith("JsonApiDotNetCore.", StringComparison.Ordinal)); + + options.AddProvider(loggerProvider); options.SetMinimumLevel(LogLevel.Trace); - }); - testContext.ConfigureServicesBeforeStartup(services => - { - services.AddSingleton(loggerFactory); + options.Services.AddSingleton(loggerProvider); }); } @@ -37,10 +36,10 @@ public LoggingTests(IntegrationTestContext, Lo public async Task Logs_request_body_at_Trace_level() { // Arrange - var loggerFactory = _testContext.Factory.Services.GetRequiredService(); - loggerFactory.Logger.Clear(); + var loggerProvider = _testContext.Factory.Services.GetRequiredService(); + loggerProvider.Clear(); - AuditEntry newEntry = _fakers.AuditEntry.Generate(); + AuditEntry newEntry = _fakers.AuditEntry.GenerateOne(); var requestBody = new { @@ -64,18 +63,18 @@ public async Task Logs_request_body_at_Trace_level() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); - loggerFactory.Logger.Messages.ShouldNotBeEmpty(); + IReadOnlyList logLines = loggerProvider.GetLines(); - loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Trace && - message.Text.StartsWith("Received POST request at 'http://localhost/auditEntries' with body: <<", StringComparison.Ordinal)); + logLines.Should().ContainSingle(line => + line.StartsWith("[TRACE] Received POST request at 'http://localhost/auditEntries' with body: <<", StringComparison.Ordinal)); } [Fact] public async Task Logs_response_body_at_Trace_level() { // Arrange - var loggerFactory = _testContext.Factory.Services.GetRequiredService(); - loggerFactory.Logger.Clear(); + var loggerProvider = _testContext.Factory.Services.GetRequiredService(); + loggerProvider.Clear(); // Arrange const string route = "/auditEntries"; @@ -86,18 +85,18 @@ public async Task Logs_response_body_at_Trace_level() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - loggerFactory.Logger.Messages.ShouldNotBeEmpty(); + IReadOnlyList logLines = loggerProvider.GetLines(); - loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Trace && - message.Text.StartsWith("Sending 200 response for GET request at 'http://localhost/auditEntries' with body: <<", StringComparison.Ordinal)); + logLines.Should().ContainSingle(line => + line.StartsWith("[TRACE] Sending 200 response for GET request at 'http://localhost/auditEntries' with body: <<", StringComparison.Ordinal)); } [Fact] public async Task Logs_invalid_request_body_error_at_Information_level() { // Arrange - var loggerFactory = _testContext.Factory.Services.GetRequiredService(); - loggerFactory.Logger.Clear(); + var loggerProvider = _testContext.Factory.Services.GetRequiredService(); + loggerProvider.Clear(); // Arrange const string requestBody = "{ \"data\" {"; @@ -110,9 +109,325 @@ public async Task Logs_invalid_request_body_error_at_Information_level() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - loggerFactory.Logger.Messages.ShouldNotBeEmpty(); + LogMessage[] infoMessages = loggerProvider.GetMessages().Where(message => message.LogLevel == LogLevel.Information).ToArray(); + infoMessages.Should().ContainSingle(message => message.Text.Contains("Failed to deserialize request body.")); + } + + [Fact] + public async Task Logs_method_parameters_of_abstract_resource_type_at_Trace_level() + { + // Arrange + var loggerProvider = _testContext.Factory.Services.GetRequiredService(); + loggerProvider.Clear(); + + var existingBowl = new FruitBowl(); + Banana existingBanana = _fakers.Banana.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.FruitBowls.Add(existingBowl); + dbContext.Fruits.Add(existingBanana); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "fruits", + id = existingBanana.StringId + } + } + }; + + string route = $"/fruitBowls/{existingBowl.StringId}/relationships/fruits"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + string[] traceLines = loggerProvider.GetMessages().Where(message => message.LogLevel == LogLevel.Trace).Select(message => message.ToString()).ToArray(); + + traceLines.Should().BeEquivalentTo(new[] + { + $$""" + [TRACE] Received POST request at 'http://localhost/fruitBowls/{{existingBowl.StringId}}/relationships/fruits' with body: <<{ + "data": [ + { + "type": "fruits", + "id": "{{existingBanana.StringId}}" + } + ] + }>> + """, + $$""" + [TRACE] Entering PostRelationshipAsync(id: {{existingBowl.StringId}}, relationshipName: "fruits", rightResourceIds: [ + { + "ClrType": "{{typeof(Fruit).FullName}}", + "StringId": "{{existingBanana.StringId}}" + } + ]) + """, + $$""" + [TRACE] Entering AddToToManyRelationshipAsync(leftId: {{existingBowl.StringId}}, relationshipName: "fruits", rightResourceIds: [ + { + "ClrType": "{{typeof(Fruit).FullName}}", + "StringId": "{{existingBanana.StringId}}" + } + ]) + """, + $$""" + [TRACE] Entering GetAsync(queryLayer: QueryLayer + { + Filter: equals(id,'{{existingBanana.Id}}') + Selection + { + FieldSelectors + { + id + } + } + } + ) + """, + $$""" + [TRACE] Entering ApplyQueryLayer(queryLayer: QueryLayer + { + Filter: equals(id,'{{existingBanana.Id}}') + Selection + { + FieldSelectors + { + id + } + } + } + ) + """, + $$""" + [TRACE] Entering AddToToManyRelationshipAsync(leftResource: null, leftId: {{existingBowl.Id}}, rightResourceIds: [ + { + "Color": "Yellow", + "LengthInCentimeters": {{existingBanana.LengthInCentimeters.ToString(CultureInfo.InvariantCulture)}}, + "WeightInKilograms": {{existingBanana.WeightInKilograms.ToString(CultureInfo.InvariantCulture)}}, + "Id": {{existingBanana.Id}}, + "StringId": "{{existingBanana.StringId}}" + } + ]) + """ + }, options => options.Using(IgnoreLineEndingsComparer.Instance).WithStrictOrdering()); + } + + [Fact] + public async Task Logs_method_parameters_of_concrete_resource_type_at_Trace_level() + { + // Arrange + var loggerProvider = _testContext.Factory.Services.GetRequiredService(); + loggerProvider.Clear(); + + var existingBowl = new FruitBowl(); + Peach existingPeach = _fakers.Peach.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.FruitBowls.Add(existingBowl); + dbContext.Fruits.Add(existingPeach); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "peaches", + id = existingPeach.StringId + } + } + }; + + string route = $"/fruitBowls/{existingBowl.StringId}/relationships/fruits"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + string[] traceLines = loggerProvider.GetMessages().Where(message => message.LogLevel == LogLevel.Trace).Select(message => message.ToString()).ToArray(); + + traceLines.Should().BeEquivalentTo(new[] + { + $$""" + [TRACE] Received POST request at 'http://localhost/fruitBowls/{{existingBowl.StringId}}/relationships/fruits' with body: <<{ + "data": [ + { + "type": "peaches", + "id": "{{existingPeach.StringId}}" + } + ] + }>> + """, + $$""" + [TRACE] Entering PostRelationshipAsync(id: {{existingBowl.StringId}}, relationshipName: "fruits", rightResourceIds: [ + { + "Color": "Red/Yellow", + "DiameterInCentimeters": 0, + "WeightInKilograms": 0, + "Id": {{existingPeach.Id}}, + "StringId": "{{existingPeach.StringId}}" + } + ]) + """, + $$""" + [TRACE] Entering AddToToManyRelationshipAsync(leftId: {{existingBowl.StringId}}, relationshipName: "fruits", rightResourceIds: [ + { + "Color": "Red/Yellow", + "DiameterInCentimeters": 0, + "WeightInKilograms": 0, + "Id": {{existingPeach.Id}}, + "StringId": "{{existingPeach.StringId}}" + } + ]) + """, + $$""" + [TRACE] Entering GetAsync(queryLayer: QueryLayer + { + Filter: equals(id,'{{existingPeach.Id}}') + Selection + { + FieldSelectors + { + id + } + } + } + ) + """, + $$""" + [TRACE] Entering ApplyQueryLayer(queryLayer: QueryLayer + { + Filter: equals(id,'{{existingPeach.Id}}') + Selection + { + FieldSelectors + { + id + } + } + } + ) + """, + $$""" + [TRACE] Entering AddToToManyRelationshipAsync(leftResource: null, leftId: {{existingBowl.Id}}, rightResourceIds: [ + { + "Color": "Red/Yellow", + "DiameterInCentimeters": 0, + "WeightInKilograms": 0, + "Id": {{existingPeach.Id}}, + "StringId": "{{existingPeach.StringId}}" + } + ]) + """ + }, options => options.Using(IgnoreLineEndingsComparer.Instance).WithStrictOrdering()); + } + + [Fact] + public async Task Logs_query_layer_and_expression_at_Debug_level() + { + // Arrange + var loggerProvider = _testContext.Factory.Services.GetRequiredService(); + loggerProvider.Clear(); + + var bowl = new FruitBowl(); + bowl.Fruits.Add(_fakers.Peach.GenerateOne()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.FruitBowls.Add(bowl); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/fruitBowls/{bowl.StringId}/fruits?filter=greaterThan(weightInKilograms,'0.1')&fields[peaches]=color&sort=-id"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Should().NotBeEmpty(); + + LogMessage queryLayerMessage = loggerProvider.GetMessages().Should() + .ContainSingle(message => message.LogLevel == LogLevel.Debug && message.Text.StartsWith("QueryLayer:", StringComparison.Ordinal)).Subject; + + queryLayerMessage.Text.Should().Be($$""" + QueryLayer: QueryLayer + { + Include: fruits + Filter: equals(id,'{{bowl.StringId}}') + Selection + { + FieldSelectors + { + id + fruits: QueryLayer + { + Filter: greaterThan(weightInKilograms,'0.1') + Sort: -id + Pagination: Page number: 1, size: 10 + Selection + { + FieldSelectors + { + color + id + } + } + } + } + } + } + + """); + + LogMessage expressionMessage = loggerProvider.GetMessages().Should().ContainSingle(message => + message.LogLevel == LogLevel.Debug && message.Text.StartsWith("Expression tree:", StringComparison.Ordinal)).Subject; - loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Information && - message.Text.Contains("Failed to deserialize request body.")); + expressionMessage.Text.Should().Be(""" + Expression tree: [Microsoft.EntityFrameworkCore.Query.EntityQueryRootExpression] + .AsNoTrackingWithIdentityResolution() + .Include("Fruits") + .Where(fruitBowl => fruitBowl.Id == value) + .Select( + fruitBowl => new FruitBowl + { + Id = fruitBowl.Id, + Fruits = fruitBowl.Fruits + .Where(fruit => fruit.WeightInKilograms > value) + .OrderByDescending(fruit => fruit.Id) + .Take(value) + .Select( + fruit => (fruit.GetType() == value) + ? (Fruit)new Peach + { + Id = fruit.Id, + WeightInKilograms = fruit.WeightInKilograms, + DiameterInCentimeters = ((Peach)fruit).DiameterInCentimeters, + Id = fruit.Id + } + : fruit) + .ToHashSet() + }) + """); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/Peach.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/Peach.cs new file mode 100644 index 0000000000..68d251666c --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Logging/Peach.cs @@ -0,0 +1,14 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Logging; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.Logging")] +public sealed class Peach : Fruit +{ + public override string Color => "Red/Yellow"; + + [Attr] + public double DiameterInCentimeters { get; set; } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaDbContext.cs index 25f6f10810..6e0b13afa5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaDbContext.cs @@ -1,16 +1,13 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; namespace JsonApiDotNetCoreTests.IntegrationTests.Meta; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class MetaDbContext : DbContext +public sealed class MetaDbContext(DbContextOptions options) + : TestableDbContext(options) { public DbSet ProductFamilies => Set(); public DbSet SupportTickets => Set(); - - public MetaDbContext(DbContextOptions options) - : base(options) - { - } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/MetaFakers.cs index 726dd82923..019dd6aa9b 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 +internal sealed class MetaFakers { - private readonly Lazy> _lazyProductFamilyFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(productFamily => productFamily.Name, faker => faker.Commerce.ProductName())); + private readonly Lazy> _lazyProductFamilyFaker = new(() => new Faker() + .MakeDeterministic() + .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() + .MakeDeterministic() + .RuleFor(supportTicket => supportTicket.Description, faker => faker.Lorem.Paragraph())); public Faker ProductFamily => _lazyProductFamilyFaker.Value; public Faker SupportTicket => _lazySupportTicketFaker.Value; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResourceMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResourceMetaTests.cs index ba15de73d7..f160c21068 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResourceMetaTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResourceMetaTests.cs @@ -20,9 +20,10 @@ public ResourceMetaTests(IntegrationTestContext, testContext.UseController(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceDefinition(); + services.AddSingleton(); }); @@ -36,7 +37,7 @@ public async Task Returns_resource_meta_from_ResourceDefinition() // Arrange var hitCounter = _testContext.Factory.Services.GetRequiredService(); - List tickets = _fakers.SupportTicket.Generate(3); + List tickets = _fakers.SupportTicket.GenerateList(3); tickets[0].Description = $"Critical: {tickets[0].Description}"; tickets[2].Description = $"Critical: {tickets[2].Description}"; @@ -55,10 +56,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(3); - responseDocument.Data.ManyValue[0].Meta.ShouldContainKey("hasHighPriority"); + responseDocument.Data.ManyValue.Should().HaveCount(3); + responseDocument.Data.ManyValue[0].Meta.Should().ContainKey("hasHighPriority"); responseDocument.Data.ManyValue[1].Meta.Should().BeNull(); - responseDocument.Data.ManyValue[2].Meta.ShouldContainKey("hasHighPriority"); + responseDocument.Data.ManyValue[2].Meta.Should().ContainKey("hasHighPriority"); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { @@ -74,8 +75,8 @@ public async Task Returns_resource_meta_from_ResourceDefinition_in_included_reso // Arrange var hitCounter = _testContext.Factory.Services.GetRequiredService(); - ProductFamily family = _fakers.ProductFamily.Generate(); - family.Tickets = _fakers.SupportTicket.Generate(1); + ProductFamily family = _fakers.ProductFamily.GenerateOne(); + family.Tickets = _fakers.SupportTicket.GenerateList(1); family.Tickets[0].Description = $"Critical: {family.Tickets[0].Description}"; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -93,9 +94,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Included.ShouldHaveCount(1); - responseDocument.Included[0].Meta.ShouldContainKey("hasHighPriority"); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Meta.Should().ContainKey("hasHighPriority"); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs index 5b86a62322..ed00732532 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/ResponseMetaTests.cs @@ -19,10 +19,7 @@ public ResponseMetaTests(IntegrationTestContext, testContext.UseController(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddSingleton(); - }); + testContext.ConfigureServices(services => services.AddSingleton()); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); options.IncludeTotalResourceCount = false; @@ -45,22 +42,24 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Should().BeJson(@"{ - ""links"": { - ""self"": ""http://localhost/supportTickets"", - ""first"": ""http://localhost/supportTickets"" - }, - ""data"": [], - ""meta"": { - ""license"": ""MIT"", - ""projectUrl"": ""https://github.com/json-api-dotnet/JsonApiDotNetCore/"", - ""versions"": [ - ""v4.0.0"", - ""v3.1.0"", - ""v2.5.2"", - ""v1.3.1"" - ] - } -}"); + responseDocument.Should().BeJson(""" + { + "links": { + "self": "http://localhost/supportTickets", + "first": "http://localhost/supportTickets" + }, + "data": [], + "meta": { + "license": "MIT", + "projectUrl": "https://github.com/json-api-dotnet/JsonApiDotNetCore/", + "versions": [ + "v4.0.0", + "v3.1.0", + "v2.5.2", + "v1.3.1" + ] + } + } + """); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicket.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicket.cs index 619542ad6d..9ce245e951 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicket.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicket.cs @@ -10,4 +10,7 @@ public sealed class SupportTicket : Identifiable { [Attr] public string Description { get; set; } = null!; + + [HasOne] + public ProductFamily? ProductFamily { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicketDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicketDefinition.cs index 6ca207d0aa..20e5078bfe 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicketDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/SupportTicketDefinition.cs @@ -4,15 +4,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Meta; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class SupportTicketDefinition : HitCountingResourceDefinition +public sealed class SupportTicketDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter) + : HitCountingResourceDefinition(resourceGraph, hitCounter) { protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.GetMeta; - public SupportTicketDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph, hitCounter) - { - } - public override IDictionary? GetMeta(SupportTicket resource) { base.GetMeta(resource); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs index eee8fa75e3..aa4eb06598 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Meta/TopLevelCountTests.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Text.Json; using FluentAssertions; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; @@ -22,29 +21,28 @@ public TopLevelCountTests(IntegrationTestContext, testContext.UseController(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => - { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); + testContext.ConfigureServices(services => services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>))); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); options.IncludeTotalResourceCount = true; } [Fact] - public async Task Renders_resource_count_for_collection() + public async Task Renders_resource_count_at_primary_resources_endpoint_with_filter() { // Arrange - SupportTicket ticket = _fakers.SupportTicket.Generate(); + List tickets = _fakers.SupportTicket.GenerateList(2); + + tickets[1].Description = "Update firmware version"; await _testContext.RunOnDatabaseAsync(async dbContext => { await dbContext.ClearTableAsync(); - dbContext.SupportTickets.Add(ticket); + dbContext.SupportTickets.AddRange(tickets); await dbContext.SaveChangesAsync(); }); - const string route = "/supportTickets"; + const string route = "/supportTickets?filter=startsWith(description,'Update ')"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -52,23 +50,40 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Meta.ShouldNotBeNull(); + responseDocument.Meta.Should().ContainTotal(1); + } + + [Fact] + public async Task Renders_resource_count_at_secondary_resources_endpoint_with_filter() + { + // Arrange + ProductFamily family = _fakers.ProductFamily.GenerateOne(); + family.Tickets = _fakers.SupportTicket.GenerateList(2); + + family.Tickets[1].Description = "Update firmware version"; - responseDocument.Meta.ShouldContainKey("total").With(value => + await _testContext.RunOnDatabaseAsync(async dbContext => { - JsonElement element = value.Should().BeOfType().Subject; - element.GetInt32().Should().Be(1); + dbContext.ProductFamilies.Add(family); + await dbContext.SaveChangesAsync(); }); + + string route = $"/productFamilies/{family.StringId}/tickets?filter=contains(description,'firmware')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Meta.Should().ContainTotal(1); } [Fact] public async Task Renders_resource_count_for_empty_collection() { // Arrange - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - }); + await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.ClearTableAsync()); const string route = "/supportTickets"; @@ -78,20 +93,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Meta.ShouldNotBeNull(); - - responseDocument.Meta.ShouldContainKey("total").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetInt32().Should().Be(0); - }); + responseDocument.Meta.Should().ContainTotal(0); } [Fact] public async Task Hides_resource_count_in_create_resource_response() { // Arrange - string newDescription = _fakers.SupportTicket.Generate().Description; + string newDescription = _fakers.SupportTicket.GenerateOne().Description; var requestBody = new { @@ -120,9 +129,9 @@ public async Task Hides_resource_count_in_create_resource_response() public async Task Hides_resource_count_in_update_resource_response() { // Arrange - SupportTicket existingTicket = _fakers.SupportTicket.Generate(); + SupportTicket existingTicket = _fakers.SupportTicket.GenerateOne(); - string newDescription = _fakers.SupportTicket.Generate().Description; + string newDescription = _fakers.SupportTicket.GenerateOne().Description; await _testContext.RunOnDatabaseAsync(async dbContext => { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/DomainFakers.cs index 8109dadf31..c536656d9d 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 +internal sealed class DomainFakers { - 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() + .MakeDeterministic() + .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() + .MakeDeterministic() + .RuleFor(domainGroup => domainGroup.Name, faker => faker.Commerce.Department())); public Faker DomainUser => _lazyDomainUserFaker.Value; public Faker DomainGroup => _lazyDomainGroupFaker.Value; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetDbContext.cs index 2dd337421a..b310d9d062 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetDbContext.cs @@ -1,16 +1,13 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.FireAndForgetDelivery; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class FireForgetDbContext : DbContext +public sealed class FireForgetDbContext(DbContextOptions options) + : TestableDbContext(options) { public DbSet Users => Set(); public DbSet Groups => Set(); - - public FireForgetDbContext(DbContextOptions options) - : base(options) - { - } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetGroupDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetGroupDefinition.cs index a148a47b20..f23dd9670e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetGroupDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetGroupDefinition.cs @@ -6,18 +6,13 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.FireAndForgetDelivery; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class FireForgetGroupDefinition : MessagingGroupDefinition +public sealed class FireForgetGroupDefinition( + IResourceGraph resourceGraph, FireForgetDbContext dbContext, MessageBroker messageBroker, ResourceDefinitionHitCounter hitCounter) + : MessagingGroupDefinition(resourceGraph, dbContext.Users, dbContext.Groups, hitCounter) { - private readonly MessageBroker _messageBroker; + private readonly MessageBroker _messageBroker = messageBroker; private DomainGroup? _groupToDelete; - public FireForgetGroupDefinition(IResourceGraph resourceGraph, FireForgetDbContext dbContext, MessageBroker messageBroker, - ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph, dbContext.Users, dbContext.Groups, hitCounter) - { - _messageBroker = messageBroker; - } - public override async Task OnWritingAsync(DomainGroup group, WriteOperationKind writeOperation, CancellationToken cancellationToken) { await base.OnWritingAsync(group, writeOperation, cancellationToken); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs index 7421c06256..a412d64d96 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs @@ -17,7 +17,7 @@ public async Task Create_group_sends_messages() var hitCounter = _testContext.Factory.Services.GetRequiredService(); var messageBroker = _testContext.Factory.Services.GetRequiredService(); - string newGroupName = _fakers.DomainGroup.Generate().Name; + string newGroupName = _fakers.DomainGroup.GenerateOne().Name; var requestBody = new { @@ -39,8 +39,8 @@ public async Task Create_group_sends_messages() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newGroupName)); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("name").WhoseValue.Should().Be(newGroupName); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { @@ -49,9 +49,9 @@ public async Task Create_group_sends_messages() (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.ShouldHaveCount(1); + messageBroker.SentMessages.Should().HaveCount(1); - Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id.Should().NotBeNull().And.Subject); var content = messageBroker.SentMessages[0].GetContentAs(); content.GroupId.Should().Be(newGroupId); @@ -65,12 +65,12 @@ public async Task Create_group_with_users_sends_messages() var hitCounter = _testContext.Factory.Services.GetRequiredService(); var messageBroker = _testContext.Factory.Services.GetRequiredService(); - DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); + DomainUser existingUserWithoutGroup = _fakers.DomainUser.GenerateOne(); - DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); - existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + DomainUser existingUserWithOtherGroup = _fakers.DomainUser.GenerateOne(); + existingUserWithOtherGroup.Group = _fakers.DomainGroup.GenerateOne(); - string newGroupName = _fakers.DomainGroup.Generate().Name; + string newGroupName = _fakers.DomainGroup.GenerateOne().Name; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -117,8 +117,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newGroupName)); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("name").WhoseValue.Should().Be(newGroupName); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { @@ -128,9 +128,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.ShouldHaveCount(3); + messageBroker.SentMessages.Should().HaveCount(3); - Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id.Should().NotBeNull().And.Subject); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.GroupId.Should().Be(newGroupId); @@ -153,9 +153,9 @@ public async Task Update_group_sends_messages() var hitCounter = _testContext.Factory.Services.GetRequiredService(); var messageBroker = _testContext.Factory.Services.GetRequiredService(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.GenerateOne(); - string newGroupName = _fakers.DomainGroup.Generate().Name; + string newGroupName = _fakers.DomainGroup.GenerateOne().Name; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -193,7 +193,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.ShouldHaveCount(1); + messageBroker.SentMessages.Should().HaveCount(1); var content = messageBroker.SentMessages[0].GetContentAs(); content.GroupId.Should().Be(existingGroup.StringId); @@ -208,18 +208,18 @@ public async Task Update_group_with_users_sends_messages() var hitCounter = _testContext.Factory.Services.GetRequiredService(); var messageBroker = _testContext.Factory.Services.GetRequiredService(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.GenerateOne(); - DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); + DomainUser existingUserWithoutGroup = _fakers.DomainUser.GenerateOne(); - DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); + DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.GenerateOne(); existingUserWithSameGroup1.Group = existingGroup; - DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); + DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.GenerateOne(); existingUserWithSameGroup2.Group = existingGroup; - DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); - existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + DomainUser existingUserWithOtherGroup = _fakers.DomainUser.GenerateOne(); + existingUserWithOtherGroup.Group = _fakers.DomainGroup.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -278,7 +278,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.ShouldHaveCount(3); + messageBroker.SentMessages.Should().HaveCount(3); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.UserId.Should().Be(existingUserWithoutGroup.Id); @@ -301,7 +301,7 @@ public async Task Delete_group_sends_messages() var hitCounter = _testContext.Factory.Services.GetRequiredService(); var messageBroker = _testContext.Factory.Services.GetRequiredService(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -325,7 +325,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.ShouldHaveCount(1); + messageBroker.SentMessages.Should().HaveCount(1); var content = messageBroker.SentMessages[0].GetContentAs(); content.GroupId.Should().Be(existingGroup.StringId); @@ -338,8 +338,8 @@ public async Task Delete_group_with_users_sends_messages() var hitCounter = _testContext.Factory.Services.GetRequiredService(); var messageBroker = _testContext.Factory.Services.GetRequiredService(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - existingGroup.Users = _fakers.DomainUser.Generate(1).ToHashSet(); + DomainGroup existingGroup = _fakers.DomainGroup.GenerateOne(); + existingGroup.Users = _fakers.DomainUser.GenerateSet(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -363,7 +363,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.ShouldHaveCount(2); + messageBroker.SentMessages.Should().HaveCount(2); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.UserId.Should().Be(existingGroup.Users.ElementAt(0).Id); @@ -380,18 +380,18 @@ public async Task Replace_users_in_group_sends_messages() var hitCounter = _testContext.Factory.Services.GetRequiredService(); var messageBroker = _testContext.Factory.Services.GetRequiredService(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.GenerateOne(); - DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); + DomainUser existingUserWithoutGroup = _fakers.DomainUser.GenerateOne(); - DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); + DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.GenerateOne(); existingUserWithSameGroup1.Group = existingGroup; - DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); + DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.GenerateOne(); existingUserWithSameGroup2.Group = existingGroup; - DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); - existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + DomainUser existingUserWithOtherGroup = _fakers.DomainUser.GenerateOne(); + existingUserWithOtherGroup.Group = _fakers.DomainGroup.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -439,7 +439,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.ShouldHaveCount(3); + messageBroker.SentMessages.Should().HaveCount(3); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.UserId.Should().Be(existingUserWithoutGroup.Id); @@ -462,15 +462,15 @@ public async Task Add_users_to_group_sends_messages() var hitCounter = _testContext.Factory.Services.GetRequiredService(); var messageBroker = _testContext.Factory.Services.GetRequiredService(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.GenerateOne(); - DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); + DomainUser existingUserWithoutGroup = _fakers.DomainUser.GenerateOne(); - DomainUser existingUserWithSameGroup = _fakers.DomainUser.Generate(); + DomainUser existingUserWithSameGroup = _fakers.DomainUser.GenerateOne(); existingUserWithSameGroup.Group = existingGroup; - DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); - existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + DomainUser existingUserWithOtherGroup = _fakers.DomainUser.GenerateOne(); + existingUserWithOtherGroup.Group = _fakers.DomainGroup.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -512,7 +512,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.ShouldHaveCount(2); + messageBroker.SentMessages.Should().HaveCount(2); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.UserId.Should().Be(existingUserWithoutGroup.Id); @@ -531,12 +531,12 @@ public async Task Remove_users_from_group_sends_messages() var hitCounter = _testContext.Factory.Services.GetRequiredService(); var messageBroker = _testContext.Factory.Services.GetRequiredService(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.GenerateOne(); - DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); + DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.GenerateOne(); existingUserWithSameGroup1.Group = existingGroup; - DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); + DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.GenerateOne(); existingUserWithSameGroup2.Group = existingGroup; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -575,7 +575,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.ShouldHaveCount(1); + messageBroker.SentMessages.Should().HaveCount(1); var content = messageBroker.SentMessages[0].GetContentAs(); content.UserId.Should().Be(existingUserWithSameGroup2.Id); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.User.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.User.cs index 1a53699b03..e8687d9993 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.User.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.User.cs @@ -17,8 +17,8 @@ public async Task Create_user_sends_messages() var hitCounter = _testContext.Factory.Services.GetRequiredService(); var messageBroker = _testContext.Factory.Services.GetRequiredService(); - string newLoginName = _fakers.DomainUser.Generate().LoginName; - string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; + string newLoginName = _fakers.DomainUser.GenerateOne().LoginName; + string newDisplayName = _fakers.DomainUser.GenerateOne().DisplayName!; var requestBody = new { @@ -41,9 +41,9 @@ public async Task Create_user_sends_messages() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("loginName").With(value => value.Should().Be(newLoginName)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("displayName").With(value => value.Should().Be(newDisplayName)); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("loginName").WhoseValue.Should().Be(newLoginName); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("displayName").WhoseValue.Should().Be(newDisplayName); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { @@ -52,9 +52,9 @@ public async Task Create_user_sends_messages() (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.ShouldHaveCount(1); + messageBroker.SentMessages.Should().HaveCount(1); - Guid newUserId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + Guid newUserId = Guid.Parse(responseDocument.Data.SingleValue.Id.Should().NotBeNull().And.Subject); var content = messageBroker.SentMessages[0].GetContentAs(); content.UserId.Should().Be(newUserId); @@ -69,9 +69,9 @@ public async Task Create_user_in_group_sends_messages() var hitCounter = _testContext.Factory.Services.GetRequiredService(); var messageBroker = _testContext.Factory.Services.GetRequiredService(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.GenerateOne(); - string newLoginName = _fakers.DomainUser.Generate().LoginName; + string newLoginName = _fakers.DomainUser.GenerateOne().LoginName; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -110,9 +110,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("loginName").With(value => value.Should().Be(newLoginName)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("displayName").With(value => value.Should().BeNull()); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("loginName").WhoseValue.Should().Be(newLoginName); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("displayName").WhoseValue.Should().BeNull(); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { @@ -122,9 +122,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.ShouldHaveCount(2); + messageBroker.SentMessages.Should().HaveCount(2); - Guid newUserId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + Guid newUserId = Guid.Parse(responseDocument.Data.SingleValue.Id.Should().NotBeNull().And.Subject); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.UserId.Should().Be(newUserId); @@ -143,10 +143,10 @@ public async Task Update_user_sends_messages() var hitCounter = _testContext.Factory.Services.GetRequiredService(); var messageBroker = _testContext.Factory.Services.GetRequiredService(); - DomainUser existingUser = _fakers.DomainUser.Generate(); + DomainUser existingUser = _fakers.DomainUser.GenerateOne(); - string newLoginName = _fakers.DomainUser.Generate().LoginName; - string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; + string newLoginName = _fakers.DomainUser.GenerateOne().LoginName; + string newDisplayName = _fakers.DomainUser.GenerateOne().DisplayName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -185,7 +185,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.ShouldHaveCount(2); + messageBroker.SentMessages.Should().HaveCount(2); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.UserId.Should().Be(existingUser.Id); @@ -205,10 +205,10 @@ public async Task Update_user_clear_group_sends_messages() var hitCounter = _testContext.Factory.Services.GetRequiredService(); var messageBroker = _testContext.Factory.Services.GetRequiredService(); - DomainUser existingUser = _fakers.DomainUser.Generate(); - existingUser.Group = _fakers.DomainGroup.Generate(); + DomainUser existingUser = _fakers.DomainUser.GenerateOne(); + existingUser.Group = _fakers.DomainGroup.GenerateOne(); - string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; + string newDisplayName = _fakers.DomainUser.GenerateOne().DisplayName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -254,7 +254,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.ShouldHaveCount(2); + messageBroker.SentMessages.Should().HaveCount(2); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.UserId.Should().Be(existingUser.Id); @@ -273,10 +273,10 @@ public async Task Update_user_add_to_group_sends_messages() var hitCounter = _testContext.Factory.Services.GetRequiredService(); var messageBroker = _testContext.Factory.Services.GetRequiredService(); - DomainUser existingUser = _fakers.DomainUser.Generate(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainUser existingUser = _fakers.DomainUser.GenerateOne(); + DomainGroup existingGroup = _fakers.DomainGroup.GenerateOne(); - string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; + string newDisplayName = _fakers.DomainUser.GenerateOne().DisplayName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -326,7 +326,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.ShouldHaveCount(2); + messageBroker.SentMessages.Should().HaveCount(2); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.UserId.Should().Be(existingUser.Id); @@ -345,12 +345,12 @@ public async Task Update_user_move_to_group_sends_messages() var hitCounter = _testContext.Factory.Services.GetRequiredService(); var messageBroker = _testContext.Factory.Services.GetRequiredService(); - DomainUser existingUser = _fakers.DomainUser.Generate(); - existingUser.Group = _fakers.DomainGroup.Generate(); + DomainUser existingUser = _fakers.DomainUser.GenerateOne(); + existingUser.Group = _fakers.DomainGroup.GenerateOne(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.GenerateOne(); - string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; + string newDisplayName = _fakers.DomainUser.GenerateOne().DisplayName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -400,7 +400,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.ShouldHaveCount(2); + messageBroker.SentMessages.Should().HaveCount(2); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.UserId.Should().Be(existingUser.Id); @@ -420,7 +420,7 @@ public async Task Delete_user_sends_messages() var hitCounter = _testContext.Factory.Services.GetRequiredService(); var messageBroker = _testContext.Factory.Services.GetRequiredService(); - DomainUser existingUser = _fakers.DomainUser.Generate(); + DomainUser existingUser = _fakers.DomainUser.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -444,7 +444,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.ShouldHaveCount(1); + messageBroker.SentMessages.Should().HaveCount(1); var content = messageBroker.SentMessages[0].GetContentAs(); content.UserId.Should().Be(existingUser.Id); @@ -457,8 +457,8 @@ public async Task Delete_user_in_group_sends_messages() var hitCounter = _testContext.Factory.Services.GetRequiredService(); var messageBroker = _testContext.Factory.Services.GetRequiredService(); - DomainUser existingUser = _fakers.DomainUser.Generate(); - existingUser.Group = _fakers.DomainGroup.Generate(); + DomainUser existingUser = _fakers.DomainUser.GenerateOne(); + existingUser.Group = _fakers.DomainGroup.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -482,7 +482,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.ShouldHaveCount(2); + messageBroker.SentMessages.Should().HaveCount(2); var content1 = messageBroker.SentMessages[0].GetContentAs(); content1.UserId.Should().Be(existingUser.Id); @@ -499,8 +499,8 @@ public async Task Clear_group_from_user_sends_messages() var hitCounter = _testContext.Factory.Services.GetRequiredService(); var messageBroker = _testContext.Factory.Services.GetRequiredService(); - DomainUser existingUser = _fakers.DomainUser.Generate(); - existingUser.Group = _fakers.DomainGroup.Generate(); + DomainUser existingUser = _fakers.DomainUser.GenerateOne(); + existingUser.Group = _fakers.DomainGroup.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -531,7 +531,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.ShouldHaveCount(1); + messageBroker.SentMessages.Should().HaveCount(1); var content = messageBroker.SentMessages[0].GetContentAs(); content.UserId.Should().Be(existingUser.Id); @@ -545,8 +545,8 @@ public async Task Assign_group_to_user_sends_messages() var hitCounter = _testContext.Factory.Services.GetRequiredService(); var messageBroker = _testContext.Factory.Services.GetRequiredService(); - DomainUser existingUser = _fakers.DomainUser.Generate(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainUser existingUser = _fakers.DomainUser.GenerateOne(); + DomainGroup existingGroup = _fakers.DomainGroup.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -581,7 +581,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.ShouldHaveCount(1); + messageBroker.SentMessages.Should().HaveCount(1); var content = messageBroker.SentMessages[0].GetContentAs(); content.UserId.Should().Be(existingUser.Id); @@ -595,10 +595,10 @@ public async Task Replace_group_for_user_sends_messages() var hitCounter = _testContext.Factory.Services.GetRequiredService(); var messageBroker = _testContext.Factory.Services.GetRequiredService(); - DomainUser existingUser = _fakers.DomainUser.Generate(); - existingUser.Group = _fakers.DomainGroup.Generate(); + DomainUser existingUser = _fakers.DomainUser.GenerateOne(); + existingUser.Group = _fakers.DomainGroup.GenerateOne(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -633,7 +633,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.ShouldHaveCount(1); + messageBroker.SentMessages.Should().HaveCount(1); var content = messageBroker.SentMessages[0].GetContentAs(); content.UserId.Should().Be(existingUser.Id); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.cs index fe7eabd112..b9ed0b5df4 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.cs @@ -20,7 +20,7 @@ public FireForgetTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceDefinition(); services.AddResourceDefinition(); @@ -53,7 +53,7 @@ public async Task Does_not_send_message_on_write_error() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -77,7 +77,7 @@ public async Task Does_not_rollback_on_message_delivery_error() var messageBroker = _testContext.Factory.Services.GetRequiredService(); messageBroker.SimulateFailure = true; - DomainUser existingUser = _fakers.DomainUser.Generate(); + DomainUser existingUser = _fakers.DomainUser.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -94,7 +94,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.ServiceUnavailable); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); @@ -107,7 +107,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - messageBroker.SentMessages.ShouldHaveCount(1); + messageBroker.SentMessages.Should().HaveCount(1); await _testContext.RunOnDatabaseAsync(async dbContext => { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetUserDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetUserDefinition.cs index c72cd667f0..86afa84846 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetUserDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetUserDefinition.cs @@ -6,18 +6,13 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.FireAndForgetDelivery; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class FireForgetUserDefinition : MessagingUserDefinition +public sealed class FireForgetUserDefinition( + IResourceGraph resourceGraph, FireForgetDbContext dbContext, MessageBroker messageBroker, ResourceDefinitionHitCounter hitCounter) + : MessagingUserDefinition(resourceGraph, dbContext.Users, hitCounter) { - private readonly MessageBroker _messageBroker; + private readonly MessageBroker _messageBroker = messageBroker; private DomainUser? _userToDelete; - public FireForgetUserDefinition(IResourceGraph resourceGraph, FireForgetDbContext dbContext, MessageBroker messageBroker, - ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph, dbContext.Users, hitCounter) - { - _messageBroker = messageBroker; - } - public override async Task OnWritingAsync(DomainUser user, WriteOperationKind writeOperation, CancellationToken cancellationToken) { await base.OnWritingAsync(user, writeOperation, cancellationToken); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupCreatedContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupCreatedContent.cs index c7c400ae4f..75abfd7170 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupCreatedContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupCreatedContent.cs @@ -3,16 +3,10 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class GroupCreatedContent : IMessageContent +public sealed class GroupCreatedContent(Guid groupId, string groupName) : IMessageContent { public int FormatVersion => 1; - public Guid GroupId { get; } - public string GroupName { get; } - - public GroupCreatedContent(Guid groupId, string groupName) - { - GroupId = groupId; - GroupName = groupName; - } + public Guid GroupId { get; } = groupId; + public string GroupName { get; } = groupName; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupDeletedContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupDeletedContent.cs index 338b88a676..1d11b80d84 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupDeletedContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupDeletedContent.cs @@ -3,14 +3,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class GroupDeletedContent : IMessageContent +public sealed class GroupDeletedContent(Guid groupId) : IMessageContent { public int FormatVersion => 1; - public Guid GroupId { get; } - - public GroupDeletedContent(Guid groupId) - { - GroupId = groupId; - } + public Guid GroupId { get; } = groupId; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupRenamedContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupRenamedContent.cs index 2412e9c8c1..d2745f5694 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupRenamedContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/GroupRenamedContent.cs @@ -3,18 +3,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class GroupRenamedContent : IMessageContent +public sealed class GroupRenamedContent(Guid groupId, string beforeGroupName, string afterGroupName) : IMessageContent { public int FormatVersion => 1; - public Guid GroupId { get; } - public string BeforeGroupName { get; } - public string AfterGroupName { get; } - - public GroupRenamedContent(Guid groupId, string beforeGroupName, string afterGroupName) - { - GroupId = groupId; - BeforeGroupName = beforeGroupName; - AfterGroupName = afterGroupName; - } + public Guid GroupId { get; } = groupId; + public string BeforeGroupName { get; } = beforeGroupName; + public string AfterGroupName { get; } = afterGroupName; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserAddedToGroupContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserAddedToGroupContent.cs index fd8400b9a4..4d61510b02 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserAddedToGroupContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserAddedToGroupContent.cs @@ -3,16 +3,10 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class UserAddedToGroupContent : IMessageContent +public sealed class UserAddedToGroupContent(Guid userId, Guid groupId) : IMessageContent { public int FormatVersion => 1; - public Guid UserId { get; } - public Guid GroupId { get; } - - public UserAddedToGroupContent(Guid userId, Guid groupId) - { - UserId = userId; - GroupId = groupId; - } + public Guid UserId { get; } = userId; + public Guid GroupId { get; } = groupId; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserCreatedContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserCreatedContent.cs index 8c9f2c08f7..774f9d3a70 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserCreatedContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserCreatedContent.cs @@ -3,18 +3,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class UserCreatedContent : IMessageContent +public sealed class UserCreatedContent(Guid userId, string userLoginName, string? userDisplayName) : IMessageContent { public int FormatVersion => 1; - public Guid UserId { get; } - public string UserLoginName { get; } - public string? UserDisplayName { get; } - - public UserCreatedContent(Guid userId, string userLoginName, string? userDisplayName) - { - UserId = userId; - UserLoginName = userLoginName; - UserDisplayName = userDisplayName; - } + public Guid UserId { get; } = userId; + public string UserLoginName { get; } = userLoginName; + public string? UserDisplayName { get; } = userDisplayName; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserDeletedContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserDeletedContent.cs index f0a5a30102..877609eb5b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserDeletedContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserDeletedContent.cs @@ -3,14 +3,9 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class UserDeletedContent : IMessageContent +public sealed class UserDeletedContent(Guid userId) : IMessageContent { public int FormatVersion => 1; - public Guid UserId { get; } - - public UserDeletedContent(Guid userId) - { - UserId = userId; - } + public Guid UserId { get; } = userId; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserDisplayNameChangedContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserDisplayNameChangedContent.cs index aff93ee7db..05d84a8fa0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserDisplayNameChangedContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserDisplayNameChangedContent.cs @@ -3,18 +3,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class UserDisplayNameChangedContent : IMessageContent +public sealed class UserDisplayNameChangedContent(Guid userId, string? beforeUserDisplayName, string? afterUserDisplayName) : IMessageContent { public int FormatVersion => 1; - public Guid UserId { get; } - public string? BeforeUserDisplayName { get; } - public string? AfterUserDisplayName { get; } - - public UserDisplayNameChangedContent(Guid userId, string? beforeUserDisplayName, string? afterUserDisplayName) - { - UserId = userId; - BeforeUserDisplayName = beforeUserDisplayName; - AfterUserDisplayName = afterUserDisplayName; - } + public Guid UserId { get; } = userId; + public string? BeforeUserDisplayName { get; } = beforeUserDisplayName; + public string? AfterUserDisplayName { get; } = afterUserDisplayName; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserLoginNameChangedContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserLoginNameChangedContent.cs index d835220aa0..bb46640e67 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserLoginNameChangedContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserLoginNameChangedContent.cs @@ -3,18 +3,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class UserLoginNameChangedContent : IMessageContent +public sealed class UserLoginNameChangedContent(Guid userId, string beforeUserLoginName, string afterUserLoginName) : IMessageContent { public int FormatVersion => 1; - public Guid UserId { get; } - public string BeforeUserLoginName { get; } - public string AfterUserLoginName { get; } - - public UserLoginNameChangedContent(Guid userId, string beforeUserLoginName, string afterUserLoginName) - { - UserId = userId; - BeforeUserLoginName = beforeUserLoginName; - AfterUserLoginName = afterUserLoginName; - } + public Guid UserId { get; } = userId; + public string BeforeUserLoginName { get; } = beforeUserLoginName; + public string AfterUserLoginName { get; } = afterUserLoginName; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserMovedToGroupContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserMovedToGroupContent.cs index 766422e595..aad81f0123 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserMovedToGroupContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserMovedToGroupContent.cs @@ -3,18 +3,11 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class UserMovedToGroupContent : IMessageContent +public sealed class UserMovedToGroupContent(Guid userId, Guid beforeGroupId, Guid afterGroupId) : IMessageContent { public int FormatVersion => 1; - public Guid UserId { get; } - public Guid BeforeGroupId { get; } - public Guid AfterGroupId { get; } - - public UserMovedToGroupContent(Guid userId, Guid beforeGroupId, Guid afterGroupId) - { - UserId = userId; - BeforeGroupId = beforeGroupId; - AfterGroupId = afterGroupId; - } + public Guid UserId { get; } = userId; + public Guid BeforeGroupId { get; } = beforeGroupId; + public Guid AfterGroupId { get; } = afterGroupId; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserRemovedFromGroupContent.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserRemovedFromGroupContent.cs index 8623d101b9..1969b7c4d7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserRemovedFromGroupContent.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/Messages/UserRemovedFromGroupContent.cs @@ -3,16 +3,10 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class UserRemovedFromGroupContent : IMessageContent +public sealed class UserRemovedFromGroupContent(Guid userId, Guid groupId) : IMessageContent { public int FormatVersion => 1; - public Guid UserId { get; } - public Guid GroupId { get; } - - public UserRemovedFromGroupContent(Guid userId, Guid groupId) - { - UserId = userId; - GroupId = groupId; - } + public Guid UserId { get; } = userId; + public Guid GroupId { get; } = groupId; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs index 9365ff08a0..048206e20f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs @@ -7,24 +7,18 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices; -public abstract class MessagingGroupDefinition : HitCountingResourceDefinition +public abstract class MessagingGroupDefinition( + IResourceGraph resourceGraph, DbSet userSet, DbSet groupSet, ResourceDefinitionHitCounter hitCounter) + : HitCountingResourceDefinition(resourceGraph, hitCounter) { - private readonly DbSet _userSet; - private readonly DbSet _groupSet; - private readonly List _pendingMessages = new(); + private readonly DbSet _userSet = userSet; + private readonly DbSet _groupSet = groupSet; + private readonly List _pendingMessages = []; private string? _beforeGroupName; protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.Writing; - protected MessagingGroupDefinition(IResourceGraph resourceGraph, DbSet userSet, DbSet groupSet, - ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph, hitCounter) - { - _userSet = userSet; - _groupSet = groupSet; - } - public override async Task OnPrepareWriteAsync(DomainGroup group, WriteOperationKind writeOperation, CancellationToken cancellationToken) { await base.OnPrepareWriteAsync(group, writeOperation, cancellationToken); @@ -59,7 +53,7 @@ public override async Task OnSetToManyRelationshipAsync(DomainGroup group, HasMa { content = new UserAddedToGroupContent(beforeUser.Id, group.Id); } - else if (beforeUser.Group != null && beforeUser.Group.Id != group.Id) + else if (beforeUser.Group.Id != group.Id) { content = new UserMovedToGroupContent(beforeUser.Id, beforeUser.Group.Id, group.Id); } @@ -100,7 +94,7 @@ public override async Task OnAddToRelationshipAsync(DomainGroup group, HasManyAt { content = new UserAddedToGroupContent(beforeUser.Id, group.Id); } - else if (beforeUser.Group != null && beforeUser.Group.Id != group.Id) + else if (beforeUser.Group.Id != group.Id) { content = new UserMovedToGroupContent(beforeUser.Id, beforeUser.Group.Id, group.Id); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingUserDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingUserDefinition.cs index 499c572b88..34e2feb786 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingUserDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/MessagingUserDefinition.cs @@ -7,22 +7,17 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices; -public abstract class MessagingUserDefinition : HitCountingResourceDefinition +public abstract class MessagingUserDefinition(IResourceGraph resourceGraph, DbSet userSet, ResourceDefinitionHitCounter hitCounter) + : HitCountingResourceDefinition(resourceGraph, hitCounter) { - private readonly DbSet _userSet; - private readonly List _pendingMessages = new(); + private readonly DbSet _userSet = userSet; + private readonly List _pendingMessages = []; private string? _beforeLoginName; private string? _beforeDisplayName; protected override ResourceDefinitionExtensibilityPoints ExtensibilityPointsToTrack => ResourceDefinitionExtensibilityPoints.Writing; - protected MessagingUserDefinition(IResourceGraph resourceGraph, DbSet userSet, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph, hitCounter) - { - _userSet = userSet; - } - public override async Task OnPrepareWriteAsync(DomainUser user, WriteOperationKind writeOperation, CancellationToken cancellationToken) { await base.OnPrepareWriteAsync(user, writeOperation, cancellationToken); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxDbContext.cs index f2a88e1c8d..ce2f82fe3f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxDbContext.cs @@ -1,18 +1,15 @@ using JetBrains.Annotations; using JsonApiDotNetCoreTests.IntegrationTests.Microservices.Messages; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.TransactionalOutboxPattern; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class OutboxDbContext : DbContext +public sealed class OutboxDbContext(DbContextOptions options) + : TestableDbContext(options) { public DbSet Users => Set(); public DbSet Groups => Set(); public DbSet OutboxMessages => Set(); - - public OutboxDbContext(DbContextOptions options) - : base(options) - { - } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxGroupDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxGroupDefinition.cs index 8aaef59e35..9e950fa653 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxGroupDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxGroupDefinition.cs @@ -7,15 +7,10 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.TransactionalOutboxPattern; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class OutboxGroupDefinition : MessagingGroupDefinition +public sealed class OutboxGroupDefinition(IResourceGraph resourceGraph, OutboxDbContext dbContext, ResourceDefinitionHitCounter hitCounter) + : MessagingGroupDefinition(resourceGraph, dbContext.Users, dbContext.Groups, hitCounter) { - private readonly DbSet _outboxMessageSet; - - public OutboxGroupDefinition(IResourceGraph resourceGraph, OutboxDbContext dbContext, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph, dbContext.Users, dbContext.Groups, hitCounter) - { - _outboxMessageSet = dbContext.OutboxMessages; - } + private readonly DbSet _outboxMessageSet = dbContext.OutboxMessages; public override async Task OnWritingAsync(DomainGroup group, WriteOperationKind writeOperation, CancellationToken cancellationToken) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs index cc5b5e84ab..47c5698e38 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs @@ -17,7 +17,7 @@ public async Task Create_group_writes_to_outbox() // Arrange var hitCounter = _testContext.Factory.Services.GetRequiredService(); - string newGroupName = _fakers.DomainGroup.Generate().Name; + string newGroupName = _fakers.DomainGroup.GenerateOne().Name; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -44,8 +44,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newGroupName)); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("name").WhoseValue.Should().Be(newGroupName); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { @@ -54,12 +54,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id.Should().NotBeNull().And.Subject); await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(1); + messages.Should().HaveCount(1); var content = messages[0].GetContentAs(); content.GroupId.Should().Be(newGroupId); @@ -73,12 +73,12 @@ public async Task Create_group_with_users_writes_to_outbox() // Arrange var hitCounter = _testContext.Factory.Services.GetRequiredService(); - DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); + DomainUser existingUserWithoutGroup = _fakers.DomainUser.GenerateOne(); - DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); - existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + DomainUser existingUserWithOtherGroup = _fakers.DomainUser.GenerateOne(); + existingUserWithOtherGroup.Group = _fakers.DomainGroup.GenerateOne(); - string newGroupName = _fakers.DomainGroup.Generate().Name; + string newGroupName = _fakers.DomainGroup.GenerateOne().Name; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -126,8 +126,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("name").With(value => value.Should().Be(newGroupName)); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("name").WhoseValue.Should().Be(newGroupName); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { @@ -137,12 +137,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainGroup), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + Guid newGroupId = Guid.Parse(responseDocument.Data.SingleValue.Id.Should().NotBeNull().And.Subject); await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(3); + messages.Should().HaveCount(3); var content1 = messages[0].GetContentAs(); content1.GroupId.Should().Be(newGroupId); @@ -165,9 +165,9 @@ public async Task Update_group_writes_to_outbox() // Arrange var hitCounter = _testContext.Factory.Services.GetRequiredService(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.GenerateOne(); - string newGroupName = _fakers.DomainGroup.Generate().Name; + string newGroupName = _fakers.DomainGroup.GenerateOne().Name; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -209,7 +209,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(1); + messages.Should().HaveCount(1); var content = messages[0].GetContentAs(); content.GroupId.Should().Be(existingGroup.StringId); @@ -224,18 +224,18 @@ public async Task Update_group_with_users_writes_to_outbox() // Arrange var hitCounter = _testContext.Factory.Services.GetRequiredService(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.GenerateOne(); - DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); + DomainUser existingUserWithoutGroup = _fakers.DomainUser.GenerateOne(); - DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); + DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.GenerateOne(); existingUserWithSameGroup1.Group = existingGroup; - DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); + DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.GenerateOne(); existingUserWithSameGroup2.Group = existingGroup; - DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); - existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + DomainUser existingUserWithOtherGroup = _fakers.DomainUser.GenerateOne(); + existingUserWithOtherGroup.Group = _fakers.DomainGroup.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -298,7 +298,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(3); + messages.Should().HaveCount(3); var content1 = messages[0].GetContentAs(); content1.UserId.Should().Be(existingUserWithoutGroup.Id); @@ -321,7 +321,7 @@ public async Task Delete_group_writes_to_outbox() // Arrange var hitCounter = _testContext.Factory.Services.GetRequiredService(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -349,7 +349,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(1); + messages.Should().HaveCount(1); var content = messages[0].GetContentAs(); content.GroupId.Should().Be(existingGroup.StringId); @@ -362,8 +362,8 @@ public async Task Delete_group_with_users_writes_to_outbox() // Arrange var hitCounter = _testContext.Factory.Services.GetRequiredService(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - existingGroup.Users = _fakers.DomainUser.Generate(1).ToHashSet(); + DomainGroup existingGroup = _fakers.DomainGroup.GenerateOne(); + existingGroup.Users = _fakers.DomainUser.GenerateSet(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -391,7 +391,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(2); + messages.Should().HaveCount(2); var content1 = messages[0].GetContentAs(); content1.UserId.Should().Be(existingGroup.Users.ElementAt(0).Id); @@ -408,18 +408,18 @@ public async Task Replace_users_in_group_writes_to_outbox() // Arrange var hitCounter = _testContext.Factory.Services.GetRequiredService(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.GenerateOne(); - DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); + DomainUser existingUserWithoutGroup = _fakers.DomainUser.GenerateOne(); - DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); + DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.GenerateOne(); existingUserWithSameGroup1.Group = existingGroup; - DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); + DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.GenerateOne(); existingUserWithSameGroup2.Group = existingGroup; - DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); - existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + DomainUser existingUserWithOtherGroup = _fakers.DomainUser.GenerateOne(); + existingUserWithOtherGroup.Group = _fakers.DomainGroup.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -471,7 +471,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(3); + messages.Should().HaveCount(3); var content1 = messages[0].GetContentAs(); content1.UserId.Should().Be(existingUserWithoutGroup.Id); @@ -494,15 +494,15 @@ public async Task Add_users_to_group_writes_to_outbox() // Arrange var hitCounter = _testContext.Factory.Services.GetRequiredService(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.GenerateOne(); - DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); + DomainUser existingUserWithoutGroup = _fakers.DomainUser.GenerateOne(); - DomainUser existingUserWithSameGroup = _fakers.DomainUser.Generate(); + DomainUser existingUserWithSameGroup = _fakers.DomainUser.GenerateOne(); existingUserWithSameGroup.Group = existingGroup; - DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); - existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); + DomainUser existingUserWithOtherGroup = _fakers.DomainUser.GenerateOne(); + existingUserWithOtherGroup.Group = _fakers.DomainGroup.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -548,7 +548,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(2); + messages.Should().HaveCount(2); var content1 = messages[0].GetContentAs(); content1.UserId.Should().Be(existingUserWithoutGroup.Id); @@ -567,12 +567,12 @@ public async Task Remove_users_from_group_writes_to_outbox() // Arrange var hitCounter = _testContext.Factory.Services.GetRequiredService(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.GenerateOne(); - DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); + DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.GenerateOne(); existingUserWithSameGroup1.Group = existingGroup; - DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); + DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.GenerateOne(); existingUserWithSameGroup2.Group = existingGroup; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -615,7 +615,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(1); + messages.Should().HaveCount(1); var content = messages[0].GetContentAs(); content.UserId.Should().Be(existingUserWithSameGroup2.Id); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs index 5ec47bb34a..006ba19764 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs @@ -17,8 +17,8 @@ public async Task Create_user_writes_to_outbox() // Arrange var hitCounter = _testContext.Factory.Services.GetRequiredService(); - string newLoginName = _fakers.DomainUser.Generate().LoginName; - string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; + string newLoginName = _fakers.DomainUser.GenerateOne().LoginName; + string newDisplayName = _fakers.DomainUser.GenerateOne().DisplayName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -46,9 +46,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("loginName").With(value => value.Should().Be(newLoginName)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("displayName").With(value => value.Should().Be(newDisplayName)); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("loginName").WhoseValue.Should().Be(newLoginName); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("displayName").WhoseValue.Should().Be(newDisplayName); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { @@ -57,12 +57,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - Guid newUserId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + Guid newUserId = Guid.Parse(responseDocument.Data.SingleValue.Id.Should().NotBeNull().And.Subject); await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(1); + messages.Should().HaveCount(1); var content = messages[0].GetContentAs(); content.UserId.Should().Be(newUserId); @@ -77,9 +77,9 @@ public async Task Create_user_in_group_writes_to_outbox() // Arrange var hitCounter = _testContext.Factory.Services.GetRequiredService(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.GenerateOne(); - string newLoginName = _fakers.DomainUser.Generate().LoginName; + string newLoginName = _fakers.DomainUser.GenerateOne().LoginName; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -119,9 +119,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("loginName").With(value => value.Should().Be(newLoginName)); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("displayName").With(value => value.Should().BeNull()); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("loginName").WhoseValue.Should().Be(newLoginName); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("displayName").WhoseValue.Should().BeNull(); hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] { @@ -131,12 +131,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => (typeof(DomainUser), ResourceDefinitionExtensibilityPoints.OnWriteSucceededAsync) }, options => options.WithStrictOrdering()); - Guid newUserId = Guid.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + Guid newUserId = Guid.Parse(responseDocument.Data.SingleValue.Id.Should().NotBeNull().And.Subject); await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(2); + messages.Should().HaveCount(2); var content1 = messages[0].GetContentAs(); content1.UserId.Should().Be(newUserId); @@ -155,10 +155,10 @@ public async Task Update_user_writes_to_outbox() // Arrange var hitCounter = _testContext.Factory.Services.GetRequiredService(); - DomainUser existingUser = _fakers.DomainUser.Generate(); + DomainUser existingUser = _fakers.DomainUser.GenerateOne(); - string newLoginName = _fakers.DomainUser.Generate().LoginName; - string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; + string newLoginName = _fakers.DomainUser.GenerateOne().LoginName; + string newDisplayName = _fakers.DomainUser.GenerateOne().DisplayName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -201,7 +201,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(2); + messages.Should().HaveCount(2); var content1 = messages[0].GetContentAs(); content1.UserId.Should().Be(existingUser.Id); @@ -221,10 +221,10 @@ public async Task Update_user_clear_group_writes_to_outbox() // Arrange var hitCounter = _testContext.Factory.Services.GetRequiredService(); - DomainUser existingUser = _fakers.DomainUser.Generate(); - existingUser.Group = _fakers.DomainGroup.Generate(); + DomainUser existingUser = _fakers.DomainUser.GenerateOne(); + existingUser.Group = _fakers.DomainGroup.GenerateOne(); - string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; + string newDisplayName = _fakers.DomainUser.GenerateOne().DisplayName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -274,7 +274,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(2); + messages.Should().HaveCount(2); var content1 = messages[0].GetContentAs(); content1.UserId.Should().Be(existingUser.Id); @@ -293,10 +293,10 @@ public async Task Update_user_add_to_group_writes_to_outbox() // Arrange var hitCounter = _testContext.Factory.Services.GetRequiredService(); - DomainUser existingUser = _fakers.DomainUser.Generate(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainUser existingUser = _fakers.DomainUser.GenerateOne(); + DomainGroup existingGroup = _fakers.DomainGroup.GenerateOne(); - string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; + string newDisplayName = _fakers.DomainUser.GenerateOne().DisplayName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -350,7 +350,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(2); + messages.Should().HaveCount(2); var content1 = messages[0].GetContentAs(); content1.UserId.Should().Be(existingUser.Id); @@ -369,12 +369,12 @@ public async Task Update_user_move_to_group_writes_to_outbox() // Arrange var hitCounter = _testContext.Factory.Services.GetRequiredService(); - DomainUser existingUser = _fakers.DomainUser.Generate(); - existingUser.Group = _fakers.DomainGroup.Generate(); + DomainUser existingUser = _fakers.DomainUser.GenerateOne(); + existingUser.Group = _fakers.DomainGroup.GenerateOne(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.GenerateOne(); - string newDisplayName = _fakers.DomainUser.Generate().DisplayName!; + string newDisplayName = _fakers.DomainUser.GenerateOne().DisplayName!; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -428,7 +428,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(2); + messages.Should().HaveCount(2); var content1 = messages[0].GetContentAs(); content1.UserId.Should().Be(existingUser.Id); @@ -448,7 +448,7 @@ public async Task Delete_user_writes_to_outbox() // Arrange var hitCounter = _testContext.Factory.Services.GetRequiredService(); - DomainUser existingUser = _fakers.DomainUser.Generate(); + DomainUser existingUser = _fakers.DomainUser.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -476,7 +476,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(1); + messages.Should().HaveCount(1); var content = messages[0].GetContentAs(); content.UserId.Should().Be(existingUser.Id); @@ -489,8 +489,8 @@ public async Task Delete_user_in_group_writes_to_outbox() // Arrange var hitCounter = _testContext.Factory.Services.GetRequiredService(); - DomainUser existingUser = _fakers.DomainUser.Generate(); - existingUser.Group = _fakers.DomainGroup.Generate(); + DomainUser existingUser = _fakers.DomainUser.GenerateOne(); + existingUser.Group = _fakers.DomainGroup.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -518,7 +518,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(2); + messages.Should().HaveCount(2); var content1 = messages[0].GetContentAs(); content1.UserId.Should().Be(existingUser.Id); @@ -535,8 +535,8 @@ public async Task Clear_group_from_user_writes_to_outbox() // Arrange var hitCounter = _testContext.Factory.Services.GetRequiredService(); - DomainUser existingUser = _fakers.DomainUser.Generate(); - existingUser.Group = _fakers.DomainGroup.Generate(); + DomainUser existingUser = _fakers.DomainUser.GenerateOne(); + existingUser.Group = _fakers.DomainGroup.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -571,7 +571,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(1); + messages.Should().HaveCount(1); var content = messages[0].GetContentAs(); content.UserId.Should().Be(existingUser.Id); @@ -585,8 +585,8 @@ public async Task Assign_group_to_user_writes_to_outbox() // Arrange var hitCounter = _testContext.Factory.Services.GetRequiredService(); - DomainUser existingUser = _fakers.DomainUser.Generate(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainUser existingUser = _fakers.DomainUser.GenerateOne(); + DomainGroup existingGroup = _fakers.DomainGroup.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -625,7 +625,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(1); + messages.Should().HaveCount(1); var content = messages[0].GetContentAs(); content.UserId.Should().Be(existingUser.Id); @@ -639,10 +639,10 @@ public async Task Replace_group_for_user_writes_to_outbox() // Arrange var hitCounter = _testContext.Factory.Services.GetRequiredService(); - DomainUser existingUser = _fakers.DomainUser.Generate(); - existingUser.Group = _fakers.DomainGroup.Generate(); + DomainUser existingUser = _fakers.DomainUser.GenerateOne(); + existingUser.Group = _fakers.DomainGroup.GenerateOne(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -681,7 +681,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.ShouldHaveCount(1); + messages.Should().HaveCount(1); var content = messages[0].GetContentAs(); content.UserId.Should().Be(existingUser.Id); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.cs index 529fcdab6c..7f4b1f072c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.cs @@ -23,7 +23,7 @@ public OutboxTests(IntegrationTestContext, Outb testContext.UseController(); testContext.UseController(); - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceDefinition(); services.AddResourceDefinition(); @@ -41,9 +41,9 @@ public async Task Does_not_add_to_outbox_on_write_error() // Arrange var hitCounter = _testContext.Factory.Services.GetRequiredService(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); + DomainGroup existingGroup = _fakers.DomainGroup.GenerateOne(); - DomainUser existingUser = _fakers.DomainUser.Generate(); + DomainUser existingUser = _fakers.DomainUser.GenerateOne(); string unknownUserId = Unknown.StringId.For(); @@ -79,7 +79,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxUserDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxUserDefinition.cs index 8076c944ec..cbf08b9aef 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxUserDefinition.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxUserDefinition.cs @@ -7,15 +7,10 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.Microservices.TransactionalOutboxPattern; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class OutboxUserDefinition : MessagingUserDefinition +public sealed class OutboxUserDefinition(IResourceGraph resourceGraph, OutboxDbContext dbContext, ResourceDefinitionHitCounter hitCounter) + : MessagingUserDefinition(resourceGraph, dbContext.Users, hitCounter) { - private readonly DbSet _outboxMessageSet; - - public OutboxUserDefinition(IResourceGraph resourceGraph, OutboxDbContext dbContext, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph, dbContext.Users, hitCounter) - { - _outboxMessageSet = dbContext.OutboxMessages; - } + private readonly DbSet _outboxMessageSet = dbContext.OutboxMessages; public override async Task OnWritingAsync(DomainUser user, WriteOperationKind writeOperation, CancellationToken cancellationToken) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyDbContext.cs index ce54a154d8..d88be62da4 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyDbContext.cs @@ -1,24 +1,20 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; // @formatter:wrap_chained_method_calls chop_always namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class MultiTenancyDbContext : DbContext +public sealed class MultiTenancyDbContext(DbContextOptions options, ITenantProvider tenantProvider) + : TestableDbContext(options) { - private readonly ITenantProvider _tenantProvider; + private readonly ITenantProvider _tenantProvider = tenantProvider; public DbSet WebShops => Set(); public DbSet WebProducts => Set(); - public MultiTenancyDbContext(DbContextOptions options, ITenantProvider tenantProvider) - : base(options) - { - _tenantProvider = tenantProvider; - } - protected override void OnModelCreating(ModelBuilder builder) { builder.Entity() @@ -30,5 +26,7 @@ protected override void OnModelCreating(ModelBuilder builder) builder.Entity() .HasQueryFilter(webProduct => webProduct.Shop.TenantId == _tenantProvider.TenantId); + + base.OnModelCreating(builder); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyFakers.cs index 564985f107..b8e64ad1ce 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 +internal sealed class MultiTenancyFakers { - private readonly Lazy> _lazyWebShopFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(webShop => webShop.Url, faker => faker.Internet.Url())); + private readonly Lazy> _lazyWebShopFaker = new(() => new Faker() + .MakeDeterministic() + .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() + .MakeDeterministic() + .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/MultiTenancy/MultiTenancyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs index b2b100b0bd..cca888a7a1 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs @@ -25,16 +25,13 @@ public MultiTenancyTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServicesBeforeStartup(services => - { - services.AddSingleton(); - services.AddScoped(); - }); - - testContext.ConfigureServicesAfterStartup(services => + testContext.ConfigureServices(services => { services.AddResourceService>(); services.AddResourceService>(); + + services.AddSingleton(); + services.AddScoped(); }); var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); @@ -46,7 +43,7 @@ public MultiTenancyTests(IntegrationTestContext shops = _fakers.WebShop.Generate(2); + List shops = _fakers.WebShop.GenerateList(2); shops[0].TenantId = OtherTenantId; shops[1].TenantId = ThisTenantId; @@ -65,7 +62,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(shops[1].StringId); } @@ -73,12 +70,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Filter_on_primary_resources_hides_other_tenants() { // Arrange - List shops = _fakers.WebShop.Generate(2); + List shops = _fakers.WebShop.GenerateList(2); shops[0].TenantId = OtherTenantId; - shops[0].Products = _fakers.WebProduct.Generate(1); + shops[0].Products = _fakers.WebProduct.GenerateList(1); shops[1].TenantId = ThisTenantId; - shops[1].Products = _fakers.WebProduct.Generate(1); + shops[1].Products = _fakers.WebProduct.GenerateList(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -95,7 +92,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(shops[1].StringId); } @@ -103,12 +100,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Get_primary_resources_with_include_hides_other_tenants() { // Arrange - List shops = _fakers.WebShop.Generate(2); + List shops = _fakers.WebShop.GenerateList(2); shops[0].TenantId = OtherTenantId; - shops[0].Products = _fakers.WebProduct.Generate(1); + shops[0].Products = _fakers.WebProduct.GenerateList(1); shops[1].TenantId = ThisTenantId; - shops[1].Products = _fakers.WebProduct.Generate(1); + shops[1].Products = _fakers.WebProduct.GenerateList(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -125,11 +122,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Type.Should().Be("webShops"); responseDocument.Data.ManyValue[0].Id.Should().Be(shops[1].StringId); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Type.Should().Be("webProducts"); responseDocument.Included[0].Id.Should().Be(shops[1].Products[0].StringId); } @@ -138,7 +135,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_get_primary_resource_by_ID_from_other_tenant() { // Arrange - WebShop shop = _fakers.WebShop.Generate(); + WebShop shop = _fakers.WebShop.GenerateOne(); shop.TenantId = OtherTenantId; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -155,7 +152,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -167,9 +164,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_get_secondary_resources_from_other_parent_tenant() { // Arrange - WebShop shop = _fakers.WebShop.Generate(); + WebShop shop = _fakers.WebShop.GenerateOne(); shop.TenantId = OtherTenantId; - shop.Products = _fakers.WebProduct.Generate(1); + shop.Products = _fakers.WebProduct.GenerateList(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -185,7 +182,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -197,8 +194,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_get_secondary_resource_from_other_parent_tenant() { // Arrange - WebProduct product = _fakers.WebProduct.Generate(); - product.Shop = _fakers.WebShop.Generate(); + WebProduct product = _fakers.WebProduct.GenerateOne(); + product.Shop = _fakers.WebShop.GenerateOne(); product.Shop.TenantId = OtherTenantId; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -215,7 +212,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -227,9 +224,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_get_ToMany_relationship_for_other_parent_tenant() { // Arrange - WebShop shop = _fakers.WebShop.Generate(); + WebShop shop = _fakers.WebShop.GenerateOne(); shop.TenantId = OtherTenantId; - shop.Products = _fakers.WebProduct.Generate(1); + shop.Products = _fakers.WebProduct.GenerateList(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -245,7 +242,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -257,8 +254,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_get_ToOne_relationship_for_other_parent_tenant() { // Arrange - WebProduct product = _fakers.WebProduct.Generate(); - product.Shop = _fakers.WebShop.Generate(); + WebProduct product = _fakers.WebProduct.GenerateOne(); + product.Shop = _fakers.WebShop.GenerateOne(); product.Shop.TenantId = OtherTenantId; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -275,7 +272,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -287,7 +284,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_create_resource() { // Arrange - string newShopUrl = _fakers.WebShop.Generate().Url; + string newShopUrl = _fakers.WebShop.GenerateOne().Url; var requestBody = new { @@ -309,11 +306,11 @@ public async Task Can_create_resource() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("url").With(value => value.Should().Be(newShopUrl)); - responseDocument.Data.SingleValue.Relationships.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("url").WhoseValue.Should().Be(newShopUrl); + responseDocument.Data.SingleValue.Relationships.Should().NotBeNull(); - int newShopId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + int newShopId = int.Parse(responseDocument.Data.SingleValue.Id.Should().NotBeNull().And.Subject); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -328,11 +325,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_create_resource_with_ToMany_relationship_to_other_tenant() { // Arrange - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); + WebProduct existingProduct = _fakers.WebProduct.GenerateOne(); + existingProduct.Shop = _fakers.WebShop.GenerateOne(); existingProduct.Shop.TenantId = OtherTenantId; - string newShopUrl = _fakers.WebShop.Generate().Url; + string newShopUrl = _fakers.WebShop.GenerateOne().Url; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -374,7 +371,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -386,10 +383,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_create_resource_with_ToOne_relationship_to_other_tenant() { // Arrange - WebShop existingShop = _fakers.WebShop.Generate(); + WebShop existingShop = _fakers.WebShop.GenerateOne(); existingShop.TenantId = OtherTenantId; - string newProductName = _fakers.WebProduct.Generate().Name; + string newProductName = _fakers.WebProduct.GenerateOne().Name; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -428,7 +425,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -440,11 +437,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_update_resource() { // Arrange - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); + WebProduct existingProduct = _fakers.WebProduct.GenerateOne(); + existingProduct.Shop = _fakers.WebShop.GenerateOne(); existingProduct.Shop.TenantId = ThisTenantId; - string newProductName = _fakers.WebProduct.Generate().Name; + string newProductName = _fakers.WebProduct.GenerateOne().Name; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -488,11 +485,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_update_resource_from_other_tenant() { // Arrange - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); + WebProduct existingProduct = _fakers.WebProduct.GenerateOne(); + existingProduct.Shop = _fakers.WebShop.GenerateOne(); existingProduct.Shop.TenantId = OtherTenantId; - string newProductName = _fakers.WebProduct.Generate().Name; + string newProductName = _fakers.WebProduct.GenerateOne().Name; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -521,7 +518,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -533,11 +530,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_update_resource_with_ToMany_relationship_to_other_tenant() { // Arrange - WebShop existingShop = _fakers.WebShop.Generate(); + WebShop existingShop = _fakers.WebShop.GenerateOne(); existingShop.TenantId = ThisTenantId; - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); + WebProduct existingProduct = _fakers.WebProduct.GenerateOne(); + existingProduct.Shop = _fakers.WebShop.GenerateOne(); existingProduct.Shop.TenantId = OtherTenantId; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -577,7 +574,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -589,11 +586,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_update_resource_with_ToOne_relationship_to_other_tenant() { // Arrange - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); + WebProduct existingProduct = _fakers.WebProduct.GenerateOne(); + existingProduct.Shop = _fakers.WebShop.GenerateOne(); existingProduct.Shop.TenantId = ThisTenantId; - WebShop existingShop = _fakers.WebShop.Generate(); + WebShop existingShop = _fakers.WebShop.GenerateOne(); existingShop.TenantId = OtherTenantId; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -630,7 +627,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -642,9 +639,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_update_ToMany_relationship_for_other_parent_tenant() { // Arrange - WebShop existingShop = _fakers.WebShop.Generate(); + WebShop existingShop = _fakers.WebShop.GenerateOne(); existingShop.TenantId = OtherTenantId; - existingShop.Products = _fakers.WebProduct.Generate(1); + existingShop.Products = _fakers.WebProduct.GenerateList(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -665,7 +662,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -677,11 +674,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_update_ToMany_relationship_to_other_tenant() { // Arrange - WebShop existingShop = _fakers.WebShop.Generate(); + WebShop existingShop = _fakers.WebShop.GenerateOne(); existingShop.TenantId = ThisTenantId; - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); + WebProduct existingProduct = _fakers.WebProduct.GenerateOne(); + existingProduct.Shop = _fakers.WebShop.GenerateOne(); existingProduct.Shop.TenantId = OtherTenantId; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -710,7 +707,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -722,8 +719,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_update_ToOne_relationship_for_other_parent_tenant() { // Arrange - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); + WebProduct existingProduct = _fakers.WebProduct.GenerateOne(); + existingProduct.Shop = _fakers.WebShop.GenerateOne(); existingProduct.Shop.TenantId = OtherTenantId; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -745,7 +742,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -757,11 +754,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_update_ToOne_relationship_to_other_tenant() { // Arrange - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); + WebProduct existingProduct = _fakers.WebProduct.GenerateOne(); + existingProduct.Shop = _fakers.WebShop.GenerateOne(); existingProduct.Shop.TenantId = ThisTenantId; - WebShop existingShop = _fakers.WebShop.Generate(); + WebShop existingShop = _fakers.WebShop.GenerateOne(); existingShop.TenantId = OtherTenantId; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -787,7 +784,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -799,11 +796,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_add_to_ToMany_relationship_for_other_parent_tenant() { // Arrange - WebShop existingShop = _fakers.WebShop.Generate(); + WebShop existingShop = _fakers.WebShop.GenerateOne(); existingShop.TenantId = OtherTenantId; - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); + WebProduct existingProduct = _fakers.WebProduct.GenerateOne(); + existingProduct.Shop = _fakers.WebShop.GenerateOne(); existingProduct.Shop.TenantId = ThisTenantId; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -832,7 +829,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -843,11 +840,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => [Fact] public async Task Cannot_add_to_ToMany_relationship_with_other_tenant() { - WebShop existingShop = _fakers.WebShop.Generate(); + WebShop existingShop = _fakers.WebShop.GenerateOne(); existingShop.TenantId = ThisTenantId; - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); + WebProduct existingProduct = _fakers.WebProduct.GenerateOne(); + existingProduct.Shop = _fakers.WebShop.GenerateOne(); existingProduct.Shop.TenantId = OtherTenantId; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -876,7 +873,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -888,9 +885,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_remove_from_ToMany_relationship_for_other_parent_tenant() { // Arrange - WebShop existingShop = _fakers.WebShop.Generate(); + WebShop existingShop = _fakers.WebShop.GenerateOne(); existingShop.TenantId = OtherTenantId; - existingShop.Products = _fakers.WebProduct.Generate(1); + existingShop.Products = _fakers.WebProduct.GenerateList(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -918,7 +915,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -930,8 +927,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_delete_resource() { // Arrange - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); + WebProduct existingProduct = _fakers.WebProduct.GenerateOne(); + existingProduct.Shop = _fakers.WebShop.GenerateOne(); existingProduct.Shop.TenantId = ThisTenantId; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -962,8 +959,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_delete_resource_from_other_tenant() { // Arrange - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); + WebProduct existingProduct = _fakers.WebProduct.GenerateOne(); + existingProduct.Shop = _fakers.WebShop.GenerateOne(); existingProduct.Shop.TenantId = OtherTenantId; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -980,7 +977,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.NotFound); @@ -992,9 +989,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Renders_links_with_tenant_route_parameter() { // Arrange - WebShop shop = _fakers.WebShop.Generate(); + WebShop shop = _fakers.WebShop.GenerateOne(); shop.TenantId = ThisTenantId; - shop.Products = _fakers.WebProduct.Generate(1); + shop.Products = _fakers.WebProduct.GenerateList(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -1011,7 +1008,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be(route); responseDocument.Links.Related.Should().BeNull(); responseDocument.Links.First.Should().Be(responseDocument.Links.Self); @@ -1019,37 +1016,37 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].With(resource => { string shopLink = $"/nld/shops/{shop.StringId}"; - resource.Links.ShouldNotBeNull(); + resource.Links.Should().NotBeNull(); resource.Links.Self.Should().Be(shopLink); - resource.Relationships.ShouldContainKey("products").With(value => + resource.Relationships.Should().ContainKey("products").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{shopLink}/relationships/products"); value.Links.Related.Should().Be($"{shopLink}/products"); }); }); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].With(resource => { string productLink = $"/nld/products/{shop.Products[0].StringId}"; - resource.Links.ShouldNotBeNull(); + resource.Links.Should().NotBeNull(); resource.Links.Self.Should().Be(productLink); - resource.Relationships.ShouldContainKey("shop").With(value => + resource.Relationships.Should().ContainKey("shop").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{productLink}/relationships/shop"); value.Links.Related.Should().Be($"{productLink}/shop"); }); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs index b46a8133d0..89a2d497dd 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; @@ -10,21 +11,18 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public class MultiTenantResourceService : JsonApiResourceService +public class MultiTenantResourceService( + ITenantProvider tenantProvider, IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, + IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, + IResourceChangeTracker resourceChangeTracker, IResourceDefinitionAccessor resourceDefinitionAccessor) + : JsonApiResourceService(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, + resourceDefinitionAccessor) where TResource : class, IIdentifiable { - private readonly ITenantProvider _tenantProvider; + private readonly ITenantProvider _tenantProvider = tenantProvider; private static bool ResourceHasTenant => typeof(IHasTenant).IsAssignableFrom(typeof(TResource)); - public MultiTenantResourceService(ITenantProvider tenantProvider, IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, - IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, - IResourceChangeTracker resourceChangeTracker, IResourceDefinitionAccessor resourceDefinitionAccessor) - : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, resourceDefinitionAccessor) - { - _tenantProvider = tenantProvider; - } - protected override async Task InitializeResourceAsync(TResource resourceForDatabase, CancellationToken cancellationToken) { await base.InitializeResourceAsync(resourceForDatabase, cancellationToken); @@ -48,21 +46,21 @@ protected override async Task InitializeResourceAsync(TResource resourceForDatab return await base.CreateAsync(resource, cancellationToken); } - public override async Task UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken) + public override async Task UpdateAsync([DisallowNull] TId id, TResource resource, CancellationToken cancellationToken) { await AssertResourcesToAssignInRelationshipsExistAsync(resource, cancellationToken); return await base.UpdateAsync(id, resource, cancellationToken); } - public override async Task SetRelationshipAsync(TId leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken) + public override async Task SetRelationshipAsync([DisallowNull] TId leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken) { await AssertRightResourcesExistAsync(rightValue, cancellationToken); await base.SetRelationshipAsync(leftId, relationshipName, rightValue, cancellationToken); } - public override async Task AddToToManyRelationshipAsync(TId leftId, string relationshipName, ISet rightResourceIds, + public override async Task AddToToManyRelationshipAsync([DisallowNull] TId leftId, string relationshipName, ISet rightResourceIds, CancellationToken cancellationToken) { _ = await GetPrimaryResourceByIdAsync(leftId, TopFieldSelection.OnlyIdAttribute, cancellationToken); @@ -71,7 +69,7 @@ public override async Task AddToToManyRelationshipAsync(TId leftId, string relat await base.AddToToManyRelationshipAsync(leftId, relationshipName, rightResourceIds, cancellationToken); } - public override async Task DeleteAsync(TId id, CancellationToken cancellationToken) + public override async Task DeleteAsync([DisallowNull] TId id, CancellationToken cancellationToken) { _ = await GetPrimaryResourceByIdAsync(id, TopFieldSelection.OnlyIdAttribute, cancellationToken); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/RouteTenantProvider.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/RouteTenantProvider.cs index f51c214f0e..253b48b58a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/RouteTenantProvider.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/RouteTenantProvider.cs @@ -2,7 +2,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy; -internal sealed class RouteTenantProvider : ITenantProvider +internal sealed class RouteTenantProvider(IHttpContextAccessor httpContextAccessor) : ITenantProvider { // In reality, this would be looked up in a database. We'll keep it hardcoded for simplicity. public static readonly IDictionary TenantRegistry = new Dictionary @@ -11,7 +11,7 @@ internal sealed class RouteTenantProvider : ITenantProvider ["ita"] = Guid.NewGuid() }; - private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IHttpContextAccessor _httpContextAccessor = httpContextAccessor; public Guid TenantId { @@ -26,9 +26,4 @@ public Guid TenantId return countryCode != null && TenantRegistry.TryGetValue(countryCode, out Guid tenantId) ? tenantId : Guid.Empty; } } - - public RouteTenantProvider(IHttpContextAccessor httpContextAccessor) - { - _httpContextAccessor = httpContextAccessor; - } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProductsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProductsController.cs index c11db3422e..4fe81d3fe3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProductsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebProductsController.cs @@ -3,13 +3,6 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy; -// Workaround for https://youtrack.jetbrains.com/issue/RSRP-487028 -public partial class WebProductsController -{ -} - [DisableRoutingConvention] [Route("{countryCode}/products")] -partial class WebProductsController -{ -} +partial class WebProductsController; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShopsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShopsController.cs index b300e0095d..1e0e53d0f6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShopsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/MultiTenancy/WebShopsController.cs @@ -3,13 +3,6 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.MultiTenancy; -// Workaround for https://youtrack.jetbrains.com/issue/RSRP-487028 -public partial class WebShopsController -{ -} - [DisableRoutingConvention] [Route("{countryCode}/shops")] -partial class WebShopsController -{ -} +partial class WebShopsController; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoard.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoard.cs index 4cb4581291..b1df54874f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoard.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoard.cs @@ -1,11 +1,13 @@ using System.ComponentModel.DataAnnotations; using JetBrains.Annotations; +using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions; [UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(GenerateControllerEndpoints = JsonApiEndpoints.None)] public sealed class DivingBoard : Identifiable { [Attr] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoardsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoardsController.cs index 55a3cd97da..3c02f9d8e2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoardsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/DivingBoardsController.cs @@ -5,11 +5,6 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions; -public sealed class DivingBoardsController : JsonApiController -{ - public DivingBoardsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, resourceGraph, loggerFactory, resourceService) - { - } -} +public sealed class DivingBoardsController( + IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceService resourceService) + : JsonApiController(options, resourceGraph, loggerFactory, resourceService); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingConventionStartup.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingConventionStartup.cs index cc0dfcd11d..6321943718 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingConventionStartup.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingConventionStartup.cs @@ -1,13 +1,13 @@ +using System.Text.Json; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public sealed class KebabCasingConventionStartup : TestableStartup - where TDbContext : DbContext + where TDbContext : TestableDbContext { protected override void SetJsonApiOptions(JsonApiOptions options) { @@ -17,7 +17,7 @@ protected override void SetJsonApiOptions(JsonApiOptions options) options.UseRelativeLinks = true; options.IncludeTotalResourceCount = true; - options.SerializerOptions.PropertyNamingPolicy = JsonKebabCaseNamingPolicy.Instance; - options.SerializerOptions.DictionaryKeyPolicy = JsonKebabCaseNamingPolicy.Instance; + options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.KebabCaseLower; + options.SerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.KebabCaseLower; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingTests.cs index ab0a4a4a7e..afa1bdfcd0 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/KebabCasingTests.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Text.Json; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using TestBuildingBlocks; @@ -24,8 +23,8 @@ public KebabCasingTests(IntegrationTestContext pools = _fakers.SwimmingPool.Generate(2); - pools[1].DivingBoards = _fakers.DivingBoard.Generate(1); + List pools = _fakers.SwimmingPool.GenerateList(2); + pools[1].DivingBoards = _fakers.DivingBoard.GenerateList(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -42,34 +41,31 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(2); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Type == "swimming-pools"); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Attributes.ShouldContainKey("is-indoor") != null); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ShouldContainKey("water-slides") != null); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ShouldContainKey("diving-boards") != null); + responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Data.ManyValue.Should().OnlyContain(resource => resource.Type == "swimming-pools"); + responseDocument.Data.ManyValue.Should().OnlyContain(resource => resource.Attributes.Should().ContainKey2("is-indoor").WhoseValue != null); + responseDocument.Data.ManyValue.Should().OnlyContain(resource => resource.Relationships.Should().ContainKey2("water-slides").WhoseValue != null); + responseDocument.Data.ManyValue.Should().OnlyContain(resource => resource.Relationships.Should().ContainKey2("diving-boards").WhoseValue != null); decimal height = pools[1].DivingBoards[0].HeightInMeters; + string link = $"/public-api/diving-boards/{pools[1].DivingBoards[0].StringId}"; - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Type.Should().Be("diving-boards"); responseDocument.Included[0].Id.Should().Be(pools[1].DivingBoards[0].StringId); - responseDocument.Included[0].Attributes.ShouldContainKey("height-in-meters").With(value => value.As().Should().BeApproximately(height)); + responseDocument.Included[0].Attributes.Should().ContainKey("height-in-meters").WhoseValue.As().Should().BeApproximately(height); responseDocument.Included[0].Relationships.Should().BeNull(); - responseDocument.Included[0].Links.ShouldNotBeNull().Self.Should().Be($"/public-api/diving-boards/{pools[1].DivingBoards[0].StringId}"); + responseDocument.Included[0].Links.RefShould().NotBeNull().And.Subject.Self.Should().Be(link); - responseDocument.Meta.ShouldContainKey("total").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetInt32().Should().Be(2); - }); + responseDocument.Meta.Should().ContainTotal(2); } [Fact] public async Task Can_filter_secondary_resources_with_sparse_fieldset() { // Arrange - SwimmingPool pool = _fakers.SwimmingPool.Generate(); - pool.WaterSlides = _fakers.WaterSlide.Generate(2); + SwimmingPool pool = _fakers.SwimmingPool.GenerateOne(); + pool.WaterSlides = _fakers.WaterSlide.GenerateList(2); pool.WaterSlides[0].LengthInMeters = 1; pool.WaterSlides[1].LengthInMeters = 5; @@ -79,8 +75,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/public-api/swimming-pools/{pool.StringId}/water-slides" + - "?filter=greaterThan(length-in-meters,'1')&fields[water-slides]=length-in-meters"; + string route = + $"/public-api/swimming-pools/{pool.StringId}/water-slides?filter=greaterThan(length-in-meters,'1')&fields[water-slides]=length-in-meters"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -88,17 +84,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Type.Should().Be("water-slides"); responseDocument.Data.ManyValue[0].Id.Should().Be(pool.WaterSlides[1].StringId); - responseDocument.Data.ManyValue[0].Attributes.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.Should().HaveCount(1); } [Fact] public async Task Can_create_resource() { // Arrange - SwimmingPool newPool = _fakers.SwimmingPool.Generate(); + SwimmingPool newPool = _fakers.SwimmingPool.GenerateOne(); var requestBody = new { @@ -120,25 +116,25 @@ public async Task Can_create_resource() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Type.Should().Be("swimming-pools"); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("is-indoor").With(value => value.Should().Be(newPool.IsIndoor)); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("is-indoor").WhoseValue.Should().Be(newPool.IsIndoor); - int newPoolId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + int newPoolId = int.Parse(responseDocument.Data.SingleValue.Id.Should().NotBeNull().And.Subject); string poolLink = $"{route}/{newPoolId}"; - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("water-slides").With(value => + responseDocument.Data.SingleValue.Relationships.Should().ContainKey("water-slides").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{poolLink}/relationships/water-slides"); value.Links.Related.Should().Be($"{poolLink}/water-slides"); }); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("diving-boards").With(value => + responseDocument.Data.SingleValue.Relationships.Should().ContainKey("diving-boards").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{poolLink}/relationships/diving-boards"); value.Links.Related.Should().Be($"{poolLink}/diving-boards"); }); @@ -165,19 +161,19 @@ public async Task Applies_casing_convention_on_error_stack_trace() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body."); - error.Meta.ShouldContainKey("stack-trace"); + error.Meta.Should().ContainKey("stack-trace"); } [Fact] public async Task Applies_casing_convention_on_source_pointer_from_ModelState() { // Arrange - DivingBoard existingBoard = _fakers.DivingBoard.Generate(); + DivingBoard existingBoard = _fakers.DivingBoard.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -206,13 +202,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Input validation failed."); error.Detail.Should().Be("The field HeightInMeters must be between 1 and 20."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/data/attributes/height-in-meters"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/NamingDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/NamingDbContext.cs index d231a5f822..8ab8a7e703 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/NamingDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/NamingDbContext.cs @@ -1,17 +1,14 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class NamingDbContext : DbContext +public sealed class NamingDbContext(DbContextOptions options) + : TestableDbContext(options) { public DbSet SwimmingPools => Set(); public DbSet WaterSlides => Set(); public DbSet DivingBoards => Set(); - - public NamingDbContext(DbContextOptions options) - : base(options) - { - } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/NamingFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/NamingFakers.cs index e60dbdf525..58e412a14b 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 +internal sealed class NamingFakers { - private readonly Lazy> _lazySwimmingPoolFaker = new(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(swimmingPool => swimmingPool.IsIndoor, faker => faker.Random.Bool())); + private readonly Lazy> _lazySwimmingPoolFaker = new(() => new Faker() + .MakeDeterministic() + .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() + .MakeDeterministic() + .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() + .MakeDeterministic() + .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/NamingConventions/PascalCasingConventionStartup.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingConventionStartup.cs index c1c8cb8fa1..dad29067cf 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingConventionStartup.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingConventionStartup.cs @@ -1,13 +1,12 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -using Microsoft.EntityFrameworkCore; using TestBuildingBlocks; namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions; [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] public sealed class PascalCasingConventionStartup : TestableStartup - where TDbContext : DbContext + where TDbContext : TestableDbContext { protected override void SetJsonApiOptions(JsonApiOptions options) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingTests.cs index 59d442fcba..9da9e4e520 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/PascalCasingTests.cs @@ -1,5 +1,4 @@ using System.Net; -using System.Text.Json; using FluentAssertions; using JsonApiDotNetCore.Serialization.Objects; using TestBuildingBlocks; @@ -24,8 +23,8 @@ public PascalCasingTests(IntegrationTestContext pools = _fakers.SwimmingPool.Generate(2); - pools[1].DivingBoards = _fakers.DivingBoard.Generate(1); + List pools = _fakers.SwimmingPool.GenerateList(2); + pools[1].DivingBoards = _fakers.DivingBoard.GenerateList(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -42,34 +41,31 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(2); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Type == "SwimmingPools"); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Attributes.ShouldContainKey("IsIndoor") != null); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ShouldContainKey("WaterSlides") != null); - responseDocument.Data.ManyValue.Should().OnlyContain(resourceObject => resourceObject.Relationships.ShouldContainKey("DivingBoards") != null); + responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Data.ManyValue.Should().OnlyContain(resource => resource.Type == "SwimmingPools"); + responseDocument.Data.ManyValue.Should().OnlyContain(resource => resource.Attributes.Should().ContainKey2("IsIndoor").WhoseValue != null); + responseDocument.Data.ManyValue.Should().OnlyContain(resource => resource.Relationships.Should().ContainKey2("WaterSlides").WhoseValue != null); + responseDocument.Data.ManyValue.Should().OnlyContain(resource => resource.Relationships.Should().ContainKey2("DivingBoards").WhoseValue != null); decimal height = pools[1].DivingBoards[0].HeightInMeters; + string link = $"/PublicApi/DivingBoards/{pools[1].DivingBoards[0].StringId}"; - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Type.Should().Be("DivingBoards"); responseDocument.Included[0].Id.Should().Be(pools[1].DivingBoards[0].StringId); - responseDocument.Included[0].Attributes.ShouldContainKey("HeightInMeters").With(value => value.As().Should().BeApproximately(height)); + responseDocument.Included[0].Attributes.Should().ContainKey("HeightInMeters").WhoseValue.As().Should().BeApproximately(height); responseDocument.Included[0].Relationships.Should().BeNull(); - responseDocument.Included[0].Links.ShouldNotBeNull().Self.Should().Be($"/PublicApi/DivingBoards/{pools[1].DivingBoards[0].StringId}"); + responseDocument.Included[0].Links.RefShould().NotBeNull().And.Subject.Self.Should().Be(link); - responseDocument.Meta.ShouldContainKey("Total").With(value => - { - JsonElement element = value.Should().BeOfType().Subject; - element.GetInt32().Should().Be(2); - }); + responseDocument.Meta.Should().ContainTotal(2, "Total"); } [Fact] public async Task Can_filter_secondary_resources_with_sparse_fieldset() { // Arrange - SwimmingPool pool = _fakers.SwimmingPool.Generate(); - pool.WaterSlides = _fakers.WaterSlide.Generate(2); + SwimmingPool pool = _fakers.SwimmingPool.GenerateOne(); + pool.WaterSlides = _fakers.WaterSlide.GenerateList(2); pool.WaterSlides[0].LengthInMeters = 1; pool.WaterSlides[1].LengthInMeters = 5; @@ -79,7 +75,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/PublicApi/SwimmingPools/{pool.StringId}/WaterSlides" + "?filter=greaterThan(LengthInMeters,'1')&fields[WaterSlides]=LengthInMeters"; + string route = $"/PublicApi/SwimmingPools/{pool.StringId}/WaterSlides?filter=greaterThan(LengthInMeters,'1')&fields[WaterSlides]=LengthInMeters"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -87,17 +83,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Type.Should().Be("WaterSlides"); responseDocument.Data.ManyValue[0].Id.Should().Be(pool.WaterSlides[1].StringId); - responseDocument.Data.ManyValue[0].Attributes.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.Should().HaveCount(1); } [Fact] public async Task Can_create_resource() { // Arrange - SwimmingPool newPool = _fakers.SwimmingPool.Generate(); + SwimmingPool newPool = _fakers.SwimmingPool.GenerateOne(); var requestBody = new { @@ -119,25 +115,25 @@ public async Task Can_create_resource() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Type.Should().Be("SwimmingPools"); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("IsIndoor").With(value => value.Should().Be(newPool.IsIndoor)); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("IsIndoor").WhoseValue.Should().Be(newPool.IsIndoor); - int newPoolId = int.Parse(responseDocument.Data.SingleValue.Id.ShouldNotBeNull()); + int newPoolId = int.Parse(responseDocument.Data.SingleValue.Id.Should().NotBeNull().And.Subject); string poolLink = $"{route}/{newPoolId}"; - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("WaterSlides").With(value => + responseDocument.Data.SingleValue.Relationships.Should().ContainKey("WaterSlides").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{poolLink}/relationships/WaterSlides"); value.Links.Related.Should().Be($"{poolLink}/WaterSlides"); }); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("DivingBoards").With(value => + responseDocument.Data.SingleValue.Relationships.Should().ContainKey("DivingBoards").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Links.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Links.Should().NotBeNull(); value.Links.Self.Should().Be($"{poolLink}/relationships/DivingBoards"); value.Links.Related.Should().Be($"{poolLink}/DivingBoards"); }); @@ -164,19 +160,19 @@ public async Task Applies_casing_convention_on_error_stack_trace() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Failed to deserialize request body."); - error.Meta.ShouldContainKey("StackTrace"); + error.Meta.Should().ContainKey("StackTrace"); } [Fact] public async Task Applies_casing_convention_on_source_pointer_from_ModelState() { // Arrange - DivingBoard existingBoard = _fakers.DivingBoard.Generate(); + DivingBoard existingBoard = _fakers.DivingBoard.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -205,13 +201,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); error.Title.Should().Be("Input validation failed."); error.Detail.Should().Be("The field HeightInMeters must be between 1 and 20."); - error.Source.ShouldNotBeNull(); + error.Source.Should().NotBeNull(); error.Source.Pointer.Should().Be("/data/attributes/HeightInMeters"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPool.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPool.cs index 2b715edc7d..ce786ff839 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPool.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPool.cs @@ -1,10 +1,12 @@ using JetBrains.Annotations; +using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions; [UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(GenerateControllerEndpoints = JsonApiEndpoints.None)] public sealed class SwimmingPool : Identifiable { [Attr] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPoolsController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPoolsController.cs index b87206097b..95c368fb0e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPoolsController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/SwimmingPoolsController.cs @@ -5,11 +5,6 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions; -public sealed class SwimmingPoolsController : JsonApiController -{ - public SwimmingPoolsController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, resourceGraph, loggerFactory, resourceService) - { - } -} +public sealed class SwimmingPoolsController( + IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceService resourceService) + : JsonApiController(options, resourceGraph, loggerFactory, resourceService); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/WaterSlide.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/WaterSlide.cs index 95bda04456..9dbd17662e 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/WaterSlide.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NamingConventions/WaterSlide.cs @@ -1,10 +1,12 @@ using JetBrains.Annotations; +using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCoreTests.IntegrationTests.NamingConventions; [UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(GenerateControllerEndpoints = JsonApiEndpoints.None)] public sealed class WaterSlide : Identifiable { [Attr] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/DuplicateKnownResourcesController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/DuplicateKnownResourcesController.cs index b127a8cc81..34a9d1d674 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/DuplicateKnownResourcesController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/DuplicateKnownResourcesController.cs @@ -5,11 +5,6 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers; -public sealed class DuplicateKnownResourcesController : JsonApiController -{ - public DuplicateKnownResourcesController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, resourceGraph, loggerFactory, resourceService) - { - } -} +public sealed class DuplicateKnownResourcesController( + IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IResourceService resourceService) + : JsonApiController(options, resourceGraph, loggerFactory, resourceService); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/DuplicateResourceControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/DuplicateResourceControllerTests.cs index b130523588..0f1b1178f4 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/DuplicateResourceControllerTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/DuplicateResourceControllerTests.cs @@ -20,6 +20,9 @@ public void Fails_at_startup_when_multiple_controllers_exist_for_same_resource_t Action action = () => _ = Factory; // Assert - action.Should().ThrowExactly().WithMessage("Multiple controllers found for resource type 'knownResources'."); + InvalidConfigurationException exception = action.Should().ThrowExactly().Which!; + exception.Message.Should().StartWith("Multiple controllers found for resource type 'knownResources': "); + exception.Message.Should().Contain($"'{typeof(KnownResourcesController).FullName}'"); + exception.Message.Should().Contain($"'{typeof(DuplicateKnownResourcesController).FullName}'"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/EmptyDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/EmptyDbContext.cs index f4a24e1d00..0717b6e3ae 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/EmptyDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/EmptyDbContext.cs @@ -1,13 +1,9 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class EmptyDbContext : DbContext -{ - public EmptyDbContext(DbContextOptions options) - : base(options) - { - } -} +public sealed class EmptyDbContext(DbContextOptions options) + : TestableDbContext(options); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/KnownDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/KnownDbContext.cs index 39014e85b3..4a884f0fb2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/KnownDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/KnownDbContext.cs @@ -1,15 +1,12 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; namespace JsonApiDotNetCoreTests.IntegrationTests.NonJsonApiControllers; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class KnownDbContext : DbContext +public sealed class KnownDbContext(DbContextOptions options) + : TestableDbContext(options) { public DbSet KnownResources => Set(); - - public KnownDbContext(DbContextOptions options) - : base(options) - { - } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiController.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiController.cs index 170a8eb194..86e99d791c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiController.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiController.cs @@ -8,18 +8,15 @@ public sealed class NonJsonApiController : ControllerBase [HttpGet] public IActionResult Get() { - string[] result = - { - "Welcome!" - }; - + string[] result = ["Welcome!"]; return Ok(result); } [HttpPost] public async Task PostAsync() { - string name = await new StreamReader(Request.Body).ReadToEndAsync(); + using var reader = new StreamReader(Request.Body, leaveOpen: true); + string name = await reader.ReadToEndAsync(); if (string.IsNullOrEmpty(name)) { diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs index 88e9ecfd82..7c0c50cfc6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs @@ -30,7 +30,8 @@ public async Task Get_skips_middleware_and_formatters() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); httpResponse.Content.Headers.ContentType.ToString().Should().Be("application/json; charset=utf-8"); string responseText = await httpResponse.Content.ReadAsStringAsync(); @@ -41,14 +42,13 @@ public async Task Get_skips_middleware_and_formatters() public async Task Post_skips_middleware_and_formatters() { // Arrange - using var request = new HttpRequestMessage(HttpMethod.Post, "/NonJsonApi") + using var request = new HttpRequestMessage(HttpMethod.Post, "/NonJsonApi"); + + request.Content = new StringContent("Jack") { - Content = new StringContent("Jack") + Headers = { - Headers = - { - ContentType = new MediaTypeHeaderValue("text/plain") - } + ContentType = new MediaTypeHeaderValue("text/plain") } }; @@ -59,7 +59,8 @@ public async Task Post_skips_middleware_and_formatters() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); string responseText = await httpResponse.Content.ReadAsStringAsync(); @@ -79,7 +80,8 @@ public async Task Post_skips_error_handler() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); string responseText = await httpResponse.Content.ReadAsStringAsync(); @@ -90,14 +92,13 @@ public async Task Post_skips_error_handler() public async Task Put_skips_middleware_and_formatters() { // Arrange - using var request = new HttpRequestMessage(HttpMethod.Put, "/NonJsonApi") + using var request = new HttpRequestMessage(HttpMethod.Put, "/NonJsonApi"); + + request.Content = new StringContent("\"Jane\"") { - Content = new StringContent("\"Jane\"") + Headers = { - Headers = - { - ContentType = new MediaTypeHeaderValue("application/json") - } + ContentType = new MediaTypeHeaderValue("application/json") } }; @@ -108,7 +109,8 @@ public async Task Put_skips_middleware_and_formatters() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); string responseText = await httpResponse.Content.ReadAsStringAsync(); @@ -128,7 +130,8 @@ public async Task Patch_skips_middleware_and_formatters() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); string responseText = await httpResponse.Content.ReadAsStringAsync(); @@ -148,7 +151,8 @@ public async Task Delete_skips_middleware_and_formatters() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - httpResponse.Content.Headers.ContentType.ShouldNotBeNull(); + + httpResponse.Content.Headers.ContentType.Should().NotBeNull(); httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); string responseText = await httpResponse.Content.ReadAsStringAsync(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/AccountPreferences.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/AccountPreferences.cs index 20c95ea769..5b00f1bb0c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/AccountPreferences.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/AccountPreferences.cs @@ -5,6 +5,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings; [UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.QueryStrings")] public sealed class AccountPreferences : Identifiable { [Attr] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs index 2f90ed2615..36a93f9ee3 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Appointment.cs @@ -5,6 +5,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings; [UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.QueryStrings")] public sealed class Appointment : Identifiable { [Attr] @@ -18,4 +19,7 @@ public sealed class Appointment : Identifiable [Attr] public DateTimeOffset EndTime { get; set; } + + [HasMany] + public IList Reminders { get; set; } = new List(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPost.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPost.cs index a628cf9355..1840f5674d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPost.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/BlogPost.cs @@ -29,6 +29,8 @@ public sealed class BlogPost : Identifiable [HasMany] public ISet Comments { get; set; } = new HashSet(); +#pragma warning disable CS0618 // Type or member is obsolete [HasOne(CanInclude = false)] +#pragma warning restore CS0618 // Type or member is obsolete public Blog? Parent { get; set; } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Calendar.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Calendar.cs index 46d82cb7fb..eaf091ec07 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Calendar.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Calendar.cs @@ -17,6 +17,9 @@ public sealed class Calendar : Identifiable [Attr] public int DefaultAppointmentDurationInMinutes { get; set; } - [HasMany] + [HasOne] + public Appointment? MostRecentAppointment { get; set; } + + [HasMany(Capabilities = HasManyCapabilities.All & ~(HasManyCapabilities.AllowView | HasManyCapabilities.AllowFilter))] public ISet Appointments { get; set; } = new HashSet(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Comment.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Comment.cs index 6c8baa0a97..84360c8862 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Comment.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Comment.cs @@ -14,6 +14,9 @@ public sealed class Comment : Identifiable [Attr] public DateTime CreatedAt { get; set; } + [Attr] + public int NumStars { get; set; } + [HasOne] public WebAccount? Author { get; set; } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseExpression.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseExpression.cs new file mode 100644 index 0000000000..7a4bfa0788 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseExpression.cs @@ -0,0 +1,82 @@ +using System.Text; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.IsUpperCase; + +/// +/// This expression allows to test if the value of a JSON:API attribute is upper case. It represents the "isUpperCase" filter function, resulting from +/// text such as: +/// +/// isUpperCase(title) +/// +/// , or: +/// +/// isUpperCase(owner.lastName) +/// +/// . +/// +internal sealed class IsUpperCaseExpression : FilterExpression +{ + public const string Keyword = "isUpperCase"; + + /// + /// The string attribute whose value to inspect. Chain format: an optional list of to-one relationships, followed by an attribute. + /// + public ResourceFieldChainExpression TargetAttribute { get; } + + public IsUpperCaseExpression(ResourceFieldChainExpression targetAttribute) + { + ArgumentNullException.ThrowIfNull(targetAttribute); + + TargetAttribute = targetAttribute; + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.DefaultVisit(this, argument); + } + + public override string ToString() + { + return InnerToString(false); + } + + public override string ToFullString() + { + return InnerToString(true); + } + + private string InnerToString(bool toFullString) + { + var builder = new StringBuilder(); + + builder.Append(Keyword); + builder.Append('('); + builder.Append(toFullString ? TargetAttribute.ToFullString() : TargetAttribute); + builder.Append(')'); + + return builder.ToString(); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj is null || GetType() != obj.GetType()) + { + return false; + } + + var other = (IsUpperCaseExpression)obj; + + return TargetAttribute.Equals(other.TargetAttribute); + } + + public override int GetHashCode() + { + return TargetAttribute.GetHashCode(); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParseTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParseTests.cs new file mode 100644 index 0000000000..05265f3e46 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParseTests.cs @@ -0,0 +1,84 @@ +using System.ComponentModel.Design; +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreTests.UnitTests.QueryStringParameters; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.IsUpperCase; + +public sealed class IsUpperCaseFilterParseTests : BaseParseTests +{ + private readonly FilterQueryStringParameterReader _reader; + + public IsUpperCaseFilterParseTests() + { + using var serviceProvider = new ServiceContainer(); + var resourceFactory = new ResourceFactory(serviceProvider); + var scopeParser = new QueryStringParameterScopeParser(); + var valueParser = new IsUpperCaseFilterParser(resourceFactory); + + _reader = new FilterQueryStringParameterReader(scopeParser, valueParser, Request, ResourceGraph, Options); + } + + [Theory] + [InlineData("filter", "isUpperCase^", "( expected.")] + [InlineData("filter", "isUpperCase(^", "Field name expected.")] + [InlineData("filter", "isUpperCase(^ ", "Unexpected whitespace.")] + [InlineData("filter", "isUpperCase(^)", "Field name expected.")] + [InlineData("filter", "isUpperCase(^'a')", "Field name expected.")] + [InlineData("filter", "isUpperCase(^some)", "Field 'some' does not exist on resource type 'blogs'.")] + [InlineData("filter", "isUpperCase(^caption)", "Field 'caption' does not exist on resource type 'blogs'.")] + [InlineData("filter", "isUpperCase(^null)", "Field name expected.")] + [InlineData("filter", "isUpperCase(title)^)", "End of expression expected.")] + [InlineData("filter", "isUpperCase(owner.preferences.^useDarkTheme)", "Attribute of type 'String' expected.")] + public void Reader_Read_Fails(string parameterName, string parameterValue, string errorMessage) + { + // Arrange + var parameterValueSource = new MarkedText(parameterValue, '^'); + + // Act + Action action = () => _reader.Read(parameterName, parameterValueSource.Text); + + // Assert + InvalidQueryStringParameterException exception = action.Should().ThrowExactly().And; + + exception.ParameterName.Should().Be(parameterName); + exception.Errors.Should().HaveCount(1); + + ErrorObject error = exception.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"{errorMessage} {parameterValueSource}"); + error.Source.Should().NotBeNull(); + error.Source.Parameter.Should().Be(parameterName); + } + + [Theory] + [InlineData("filter", "isUpperCase(title)", null)] + [InlineData("filter", "isUpperCase(owner.userName)", null)] + [InlineData("filter", "has(posts,isUpperCase(author.userName))", null)] + [InlineData("filter", "or(isUpperCase(title),isUpperCase(platformName))", null)] + [InlineData("filter[posts]", "isUpperCase(author.userName)", "posts")] + public void Reader_Read_Succeeds(string parameterName, string parameterValue, string? scopeExpected) + { + // Act + _reader.Read(parameterName, parameterValue); + + IReadOnlyCollection constraints = _reader.GetConstraints(); + + // Assert + ResourceFieldChainExpression? scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); + scope?.ToString().Should().Be(scopeExpected); + + QueryExpression value = constraints.Select(expressionInScope => expressionInScope.Expression).Single(); + value.ToString().Should().Be(parameterValue); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParser.cs new file mode 100644 index 0000000000..99c5b30bc2 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterParser.cs @@ -0,0 +1,44 @@ +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings.FieldChains; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.IsUpperCase; + +internal sealed class IsUpperCaseFilterParser(IResourceFactory resourceFactory) + : FilterParser(resourceFactory) +{ + protected override FilterExpression ParseFilter() + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text, Value: IsUpperCaseExpression.Keyword }) + { + return ParseIsUpperCase(); + } + + return base.ParseFilter(); + } + + private IsUpperCaseExpression ParseIsUpperCase() + { + EatText(IsUpperCaseExpression.Keyword); + EatSingleCharacterToken(TokenKind.OpenParen); + + int chainStartPosition = GetNextTokenPositionOrEnd(); + + ResourceFieldChainExpression targetAttributeChain = + ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + + ResourceFieldAttribute attribute = targetAttributeChain.Fields[^1]; + + if (attribute.Property.PropertyType != typeof(string)) + { + int position = chainStartPosition + GetRelativePositionOfLastFieldInChain(targetAttributeChain); + throw new QueryParseException("Attribute of type 'String' expected.", position); + } + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new IsUpperCaseExpression(targetAttributeChain); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterTests.cs new file mode 100644 index 0000000000..fa991e7544 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseFilterTests.cs @@ -0,0 +1,129 @@ +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.Queries.QueryableBuilding; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.IsUpperCase; + +public sealed class IsUpperCaseFilterTests : IClassFixture, QueryStringDbContext>> +{ + private readonly IntegrationTestContext, QueryStringDbContext> _testContext; + private readonly QueryStringFakers _fakers = new(); + + public IsUpperCaseFilterTests(IntegrationTestContext, QueryStringDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + + testContext.ConfigureServices(services => + { + services.AddTransient(); + services.AddTransient(); + }); + } + + [Fact] + public async Task Can_filter_casing_at_primary_endpoint() + { + // Arrange + List blogs = _fakers.Blog.GenerateList(2); + + blogs[0].Title = blogs[0].Title.ToLowerInvariant(); + blogs[1].Title = blogs[1].Title.ToUpperInvariant(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?filter=isUpperCase(title)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogs"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); + } + + [Fact] + public async Task Can_filter_casing_in_compound_expression_at_secondary_endpoint() + { + // Arrange + Blog blog = _fakers.Blog.GenerateOne(); + blog.Posts = _fakers.BlogPost.GenerateList(3); + + blog.Posts[0].Caption = blog.Posts[0].Caption.ToUpperInvariant(); + blog.Posts[0].Url = blog.Posts[0].Url.ToUpperInvariant(); + + blog.Posts[1].Caption = blog.Posts[1].Caption.ToUpperInvariant(); + blog.Posts[1].Url = blog.Posts[1].Url.ToLowerInvariant(); + + blog.Posts[2].Caption = blog.Posts[2].Caption.ToLowerInvariant(); + blog.Posts[2].Url = blog.Posts[2].Url.ToLowerInvariant(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Add(blog); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/blogs/{blog.StringId}/posts?filter=and(isUpperCase(caption),not(isUpperCase(url)))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogPosts"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blog.Posts[1].StringId); + } + + [Fact] + public async Task Can_filter_casing_in_included_resources() + { + // Arrange + List blogs = _fakers.Blog.GenerateList(2); + blogs[0].Title = blogs[0].Title.ToLowerInvariant(); + blogs[1].Title = blogs[1].Title.ToUpperInvariant(); + + blogs[1].Posts = _fakers.BlogPost.GenerateList(2); + blogs[1].Posts[0].Caption = blogs[1].Posts[0].Caption.ToLowerInvariant(); + blogs[1].Posts[1].Caption = blogs[1].Posts[1].Caption.ToUpperInvariant(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?filter=isUpperCase(title)&include=posts&filter[posts]=isUpperCase(caption)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogs"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("blogPosts"); + responseDocument.Included[0].Id.Should().Be(blogs[1].Posts[1].StringId); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseWhereClauseBuilder.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseWhereClauseBuilder.cs new file mode 100644 index 0000000000..4968392ee4 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/IsUpperCase/IsUpperCaseWhereClauseBuilder.cs @@ -0,0 +1,29 @@ +using System.Linq.Expressions; +using System.Reflection; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.QueryableBuilding; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.IsUpperCase; + +internal sealed class IsUpperCaseWhereClauseBuilder : WhereClauseBuilder +{ + private static readonly MethodInfo ToUpperMethod = typeof(string).GetMethod("ToUpper", Type.EmptyTypes)!; + + public override Expression DefaultVisit(QueryExpression expression, QueryClauseBuilderContext context) + { + if (expression is IsUpperCaseExpression isUpperCaseExpression) + { + return VisitIsUpperCase(isUpperCaseExpression, context); + } + + return base.DefaultVisit(expression, context); + } + + private BinaryExpression VisitIsUpperCase(IsUpperCaseExpression expression, QueryClauseBuilderContext context) + { + Expression propertyAccess = Visit(expression.TargetAttribute, context); + MethodCallExpression toUpperMethodCall = Expression.Call(propertyAccess, ToUpperMethod); + + return Expression.Equal(propertyAccess, toUpperMethodCall); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthExpression.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthExpression.cs new file mode 100644 index 0000000000..1fcf8b45dd --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthExpression.cs @@ -0,0 +1,86 @@ +using System.Text; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.StringLength; + +/// +/// This expression allows to determine the string length of a JSON:API attribute. It represents the "length" function, resulting from text such as: +/// +/// length(title) +/// +/// , or: +/// +/// length(owner.lastName) +/// +/// . +/// +internal sealed class LengthExpression : FunctionExpression +{ + public const string Keyword = "length"; + + /// + /// The string attribute whose length to determine. Chain format: an optional list of to-one relationships, followed by an attribute. + /// + public ResourceFieldChainExpression TargetAttribute { get; } + + /// + /// The CLR type this function returns, which is always . + /// + public override Type ReturnType { get; } = typeof(int); + + public LengthExpression(ResourceFieldChainExpression targetAttribute) + { + ArgumentNullException.ThrowIfNull(targetAttribute); + + TargetAttribute = targetAttribute; + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.DefaultVisit(this, argument); + } + + public override string ToString() + { + return InnerToString(false); + } + + public override string ToFullString() + { + return InnerToString(true); + } + + private string InnerToString(bool toFullString) + { + var builder = new StringBuilder(); + + builder.Append(Keyword); + builder.Append('('); + builder.Append(toFullString ? TargetAttribute.ToFullString() : TargetAttribute); + builder.Append(')'); + + return builder.ToString(); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj is null || GetType() != obj.GetType()) + { + return false; + } + + var other = (LengthExpression)obj; + + return TargetAttribute.Equals(other.TargetAttribute); + } + + public override int GetHashCode() + { + return TargetAttribute.GetHashCode(); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParseTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParseTests.cs new file mode 100644 index 0000000000..92c104f5a4 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParseTests.cs @@ -0,0 +1,84 @@ +using System.ComponentModel.Design; +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreTests.UnitTests.QueryStringParameters; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.StringLength; + +public sealed class LengthFilterParseTests : BaseParseTests +{ + private readonly FilterQueryStringParameterReader _reader; + + public LengthFilterParseTests() + { + using var serviceProvider = new ServiceContainer(); + var resourceFactory = new ResourceFactory(serviceProvider); + var scopeParser = new QueryStringParameterScopeParser(); + var valueParser = new LengthFilterParser(resourceFactory); + + _reader = new FilterQueryStringParameterReader(scopeParser, valueParser, Request, ResourceGraph, Options); + } + + [Theory] + [InlineData("filter", "equals(length^", "( expected.")] + [InlineData("filter", "equals(length(^", "Field name expected.")] + [InlineData("filter", "equals(length(^ ", "Unexpected whitespace.")] + [InlineData("filter", "equals(length(^)", "Field name expected.")] + [InlineData("filter", "equals(length(^'a')", "Field name expected.")] + [InlineData("filter", "equals(length(^some)", "Field 'some' does not exist on resource type 'blogs'.")] + [InlineData("filter", "equals(length(^caption)", "Field 'caption' does not exist on resource type 'blogs'.")] + [InlineData("filter", "equals(length(^null)", "Field name expected.")] + [InlineData("filter", "equals(length(title)^)", ", expected.")] + [InlineData("filter", "equals(length(owner.preferences.^useDarkTheme)", "Attribute of type 'String' expected.")] + public void Reader_Read_Fails(string parameterName, string parameterValue, string errorMessage) + { + // Arrange + var parameterValueSource = new MarkedText(parameterValue, '^'); + + // Act + Action action = () => _reader.Read(parameterName, parameterValueSource.Text); + + // Assert + InvalidQueryStringParameterException exception = action.Should().ThrowExactly().And; + + exception.ParameterName.Should().Be(parameterName); + exception.Errors.Should().HaveCount(1); + + ErrorObject error = exception.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"{errorMessage} {parameterValueSource}"); + error.Source.Should().NotBeNull(); + error.Source.Parameter.Should().Be(parameterName); + } + + [Theory] + [InlineData("filter", "equals(length(title),'1')", null)] + [InlineData("filter", "greaterThan(length(owner.userName),'1')", null)] + [InlineData("filter", "has(posts,lessThan(length(author.userName),'1'))", null)] + [InlineData("filter", "or(equals(length(title),'1'),equals(length(platformName),'1'))", null)] + [InlineData("filter[posts]", "equals(length(author.userName),'1')", "posts")] + public void Reader_Read_Succeeds(string parameterName, string parameterValue, string? scopeExpected) + { + // Act + _reader.Read(parameterName, parameterValue); + + IReadOnlyCollection constraints = _reader.GetConstraints(); + + // Assert + ResourceFieldChainExpression? scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); + scope?.ToString().Should().Be(scopeExpected); + + QueryExpression value = constraints.Select(expressionInScope => expressionInScope.Expression).Single(); + value.ToString().Should().Be(parameterValue); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParser.cs new file mode 100644 index 0000000000..f787319373 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterParser.cs @@ -0,0 +1,54 @@ +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings.FieldChains; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.StringLength; + +internal sealed class LengthFilterParser(IResourceFactory resourceFactory) + : FilterParser(resourceFactory) +{ + protected override bool IsFunction(string name) + { + if (name == LengthExpression.Keyword) + { + return true; + } + + return base.IsFunction(name); + } + + protected override FunctionExpression ParseFunction() + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text, Value: LengthExpression.Keyword }) + { + return ParseLength(); + } + + return base.ParseFunction(); + } + + private LengthExpression ParseLength() + { + EatText(LengthExpression.Keyword); + EatSingleCharacterToken(TokenKind.OpenParen); + + int chainStartPosition = GetNextTokenPositionOrEnd(); + + ResourceFieldChainExpression targetAttributeChain = + ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, ResourceTypeInScope, null); + + ResourceFieldAttribute attribute = targetAttributeChain.Fields[^1]; + + if (attribute.Property.PropertyType != typeof(string)) + { + int position = chainStartPosition + GetRelativePositionOfLastFieldInChain(targetAttributeChain); + throw new QueryParseException("Attribute of type 'String' expected.", position); + } + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new LengthExpression(targetAttributeChain); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterTests.cs new file mode 100644 index 0000000000..da5091eed5 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthFilterTests.cs @@ -0,0 +1,129 @@ +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.Queries.QueryableBuilding; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.StringLength; + +public sealed class LengthFilterTests : IClassFixture, QueryStringDbContext>> +{ + private readonly IntegrationTestContext, QueryStringDbContext> _testContext; + private readonly QueryStringFakers _fakers = new(); + + public LengthFilterTests(IntegrationTestContext, QueryStringDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + + testContext.ConfigureServices(services => + { + services.AddTransient(); + services.AddTransient(); + }); + } + + [Fact] + public async Task Can_filter_length_at_primary_endpoint() + { + // Arrange + List blogs = _fakers.Blog.GenerateList(2); + + blogs[0].Title = "X"; + blogs[1].Title = "XXX"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?filter=greaterThan(length(title),'2')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogs"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); + } + + [Fact] + public async Task Can_filter_length_at_secondary_endpoint() + { + // Arrange + Blog blog = _fakers.Blog.GenerateOne(); + blog.Posts = _fakers.BlogPost.GenerateList(3); + + blog.Posts[0].Caption = "XXX"; + blog.Posts[0].Url = "YYY"; + + blog.Posts[1].Caption = "XXX"; + blog.Posts[1].Url = "Y"; + + blog.Posts[2].Caption = "X"; + blog.Posts[2].Url = "Y"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Add(blog); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/blogs/{blog.StringId}/posts?filter=greaterThan(length(caption),length(url))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogPosts"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blog.Posts[1].StringId); + } + + [Fact] + public async Task Can_filter_length_in_included_resources() + { + // Arrange + List blogs = _fakers.Blog.GenerateList(2); + blogs[0].Title = "X"; + blogs[1].Title = "XXX"; + + blogs[1].Posts = _fakers.BlogPost.GenerateList(2); + blogs[1].Posts[0].Caption = "Y"; + blogs[1].Posts[1].Caption = "YYY"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?filter=equals(length(title),'3')&include=posts&filter[posts]=equals(length(caption),'3')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogs"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("blogPosts"); + responseDocument.Included[0].Id.Should().Be(blogs[1].Posts[1].StringId); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthOrderClauseBuilder.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthOrderClauseBuilder.cs new file mode 100644 index 0000000000..bb530a37b8 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthOrderClauseBuilder.cs @@ -0,0 +1,27 @@ +using System.Linq.Expressions; +using System.Reflection; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.QueryableBuilding; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.StringLength; + +internal sealed class LengthOrderClauseBuilder : OrderClauseBuilder +{ + private static readonly MethodInfo LengthPropertyGetter = typeof(string).GetProperty("Length")!.GetGetMethod()!; + + public override Expression DefaultVisit(QueryExpression expression, QueryClauseBuilderContext context) + { + if (expression is LengthExpression lengthExpression) + { + return VisitLength(lengthExpression, context); + } + + return base.DefaultVisit(expression, context); + } + + private MemberExpression VisitLength(LengthExpression expression, QueryClauseBuilderContext context) + { + Expression propertyAccess = Visit(expression.TargetAttribute, context); + return Expression.Property(propertyAccess, LengthPropertyGetter); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParseTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParseTests.cs new file mode 100644 index 0000000000..9e55ecb901 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParseTests.cs @@ -0,0 +1,79 @@ +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreTests.UnitTests.QueryStringParameters; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.StringLength; + +public sealed class LengthSortParseTests : BaseParseTests +{ + private readonly SortQueryStringParameterReader _reader; + + public LengthSortParseTests() + { + var scopeParser = new QueryStringParameterScopeParser(); + var valueParser = new LengthSortParser(); + + _reader = new SortQueryStringParameterReader(scopeParser, valueParser, Request, ResourceGraph); + } + + [Theory] + [InlineData("sort", "length^", "( expected.")] + [InlineData("sort", "length(^", "Field name expected.")] + [InlineData("sort", "length(^ ", "Unexpected whitespace.")] + [InlineData("sort", "length(^)", "Field name expected.")] + [InlineData("sort", "length(^'a')", "Field name expected.")] + [InlineData("sort", "length(^some)", "Field 'some' does not exist on resource type 'blogs'.")] + [InlineData("sort", "length(^caption)", "Field 'caption' does not exist on resource type 'blogs'.")] + [InlineData("sort", "length(^null)", "Field name expected.")] + [InlineData("sort", "length(title)^)", ", expected.")] + [InlineData("sort", "length(owner.preferences.^useDarkTheme)", "Attribute of type 'String' expected.")] + public void Reader_Read_Fails(string parameterName, string parameterValue, string errorMessage) + { + // Arrange + var parameterValueSource = new MarkedText(parameterValue, '^'); + + // Act + Action action = () => _reader.Read(parameterName, parameterValueSource.Text); + + // Assert + InvalidQueryStringParameterException exception = action.Should().ThrowExactly().And; + + exception.ParameterName.Should().Be(parameterName); + exception.Errors.Should().HaveCount(1); + + ErrorObject error = exception.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified sort is invalid."); + error.Detail.Should().Be($"{errorMessage} {parameterValueSource}"); + error.Source.Should().NotBeNull(); + error.Source.Parameter.Should().Be(parameterName); + } + + [Theory] + [InlineData("sort", "length(title)", null)] + [InlineData("sort", "length(title),-length(platformName)", null)] + [InlineData("sort", "length(owner.userName)", null)] + [InlineData("sort[posts]", "length(author.userName)", "posts")] + public void Reader_Read_Succeeds(string parameterName, string parameterValue, string? scopeExpected) + { + // Act + _reader.Read(parameterName, parameterValue); + + IReadOnlyCollection constraints = _reader.GetConstraints(); + + // Assert + ResourceFieldChainExpression? scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); + scope?.ToString().Should().Be(scopeExpected); + + QueryExpression value = constraints.Select(expressionInScope => expressionInScope.Expression).Single(); + value.ToString().Should().Be(parameterValue); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParser.cs new file mode 100644 index 0000000000..6b24d4d4bc --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortParser.cs @@ -0,0 +1,53 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings.FieldChains; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.StringLength; + +internal sealed class LengthSortParser : SortParser +{ + protected override bool IsFunction(string name) + { + if (name == LengthExpression.Keyword) + { + return true; + } + + return base.IsFunction(name); + } + + protected override FunctionExpression ParseFunction(ResourceType resourceType) + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text, Value: LengthExpression.Keyword }) + { + return ParseLength(resourceType); + } + + return base.ParseFunction(resourceType); + } + + private LengthExpression ParseLength(ResourceType resourceType) + { + EatText(LengthExpression.Keyword); + EatSingleCharacterToken(TokenKind.OpenParen); + + int chainStartPosition = GetNextTokenPositionOrEnd(); + + ResourceFieldChainExpression targetAttributeChain = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, + FieldChainPatternMatchOptions.AllowDerivedTypes, resourceType, null); + + ResourceFieldAttribute attribute = targetAttributeChain.Fields[^1]; + + if (attribute.Property.PropertyType != typeof(string)) + { + int position = chainStartPosition + GetRelativePositionOfLastFieldInChain(targetAttributeChain); + throw new QueryParseException("Attribute of type 'String' expected.", position); + } + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new LengthExpression(targetAttributeChain); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortTests.cs new file mode 100644 index 0000000000..078d48029a --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthSortTests.cs @@ -0,0 +1,148 @@ +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.Queries.QueryableBuilding; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.StringLength; + +public sealed class LengthSortTests : IClassFixture, QueryStringDbContext>> +{ + private readonly IntegrationTestContext, QueryStringDbContext> _testContext; + private readonly QueryStringFakers _fakers = new(); + + public LengthSortTests(IntegrationTestContext, QueryStringDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + + testContext.ConfigureServices(services => + { + services.AddTransient(); + services.AddTransient(); + }); + } + + [Fact] + public async Task Can_sort_on_length_at_primary_endpoint() + { + // Arrange + List blogs = _fakers.Blog.GenerateList(2); + + blogs[0].Title = "X"; + blogs[1].Title = "XXX"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?sort=-length(title)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(2); + + responseDocument.Data.ManyValue[0].Type.Should().Be("blogs"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); + + responseDocument.Data.ManyValue[1].Type.Should().Be("blogs"); + responseDocument.Data.ManyValue[1].Id.Should().Be(blogs[0].StringId); + } + + [Fact] + public async Task Can_sort_on_length_at_secondary_endpoint() + { + // Arrange + Blog blog = _fakers.Blog.GenerateOne(); + blog.Posts = _fakers.BlogPost.GenerateList(3); + + blog.Posts[0].Caption = "XXX"; + blog.Posts[0].Url = "YYY"; + + blog.Posts[1].Caption = "XXX"; + blog.Posts[1].Url = "Y"; + + blog.Posts[2].Caption = "X"; + blog.Posts[2].Url = "Y"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Blogs.Add(blog); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/blogs/{blog.StringId}/posts?sort=length(caption),length(url)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(3); + + responseDocument.Data.ManyValue[0].Type.Should().Be("blogPosts"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blog.Posts[2].StringId); + + responseDocument.Data.ManyValue[1].Type.Should().Be("blogPosts"); + responseDocument.Data.ManyValue[1].Id.Should().Be(blog.Posts[1].StringId); + + responseDocument.Data.ManyValue[2].Type.Should().Be("blogPosts"); + responseDocument.Data.ManyValue[2].Id.Should().Be(blog.Posts[0].StringId); + } + + [Fact] + public async Task Can_sort_on_length_in_included_resources() + { + // Arrange + List blogs = _fakers.Blog.GenerateList(2); + blogs[0].Title = "XXX"; + blogs[1].Title = "X"; + + blogs[1].Posts = _fakers.BlogPost.GenerateList(2); + blogs[1].Posts[0].Caption = "YYY"; + blogs[1].Posts[1].Caption = "Y"; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.AddRange(blogs); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?sort=length(title)&include=posts&sort[posts]=length(caption)"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(2); + + responseDocument.Data.ManyValue[0].Type.Should().Be("blogs"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); + + responseDocument.Data.ManyValue[1].Type.Should().Be("blogs"); + responseDocument.Data.ManyValue[1].Id.Should().Be(blogs[0].StringId); + + responseDocument.Included.Should().HaveCount(2); + + responseDocument.Included[0].Type.Should().Be("blogPosts"); + responseDocument.Included[0].Id.Should().Be(blogs[1].Posts[1].StringId); + + responseDocument.Included[1].Type.Should().Be("blogPosts"); + responseDocument.Included[1].Id.Should().Be(blogs[1].Posts[0].StringId); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthWhereClauseBuilder.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthWhereClauseBuilder.cs new file mode 100644 index 0000000000..282b3416b4 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/StringLength/LengthWhereClauseBuilder.cs @@ -0,0 +1,27 @@ +using System.Linq.Expressions; +using System.Reflection; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.QueryableBuilding; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.StringLength; + +internal sealed class LengthWhereClauseBuilder : WhereClauseBuilder +{ + private static readonly MethodInfo LengthPropertyGetter = typeof(string).GetProperty("Length")!.GetGetMethod()!; + + public override Expression DefaultVisit(QueryExpression expression, QueryClauseBuilderContext context) + { + if (expression is LengthExpression lengthExpression) + { + return VisitLength(lengthExpression, context); + } + + return base.DefaultVisit(expression, context); + } + + private MemberExpression VisitLength(LengthExpression expression, QueryClauseBuilderContext context) + { + Expression propertyAccess = Visit(expression.TargetAttribute, context); + return Expression.Property(propertyAccess, LengthPropertyGetter); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumExpression.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumExpression.cs new file mode 100644 index 0000000000..f353c1552b --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumExpression.cs @@ -0,0 +1,97 @@ +using System.Text; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.Sum; + +/// +/// This expression allows to determine the sum of values in the related resources of a to-many relationship. It represents the "sum" function, resulting +/// from text such as: +/// +/// sum(orderLines,quantity) +/// +/// , or: +/// +/// sum(friends,count(children)) +/// +/// . +/// +internal sealed class SumExpression : FunctionExpression +{ + public const string Keyword = "sum"; + + /// + /// The to-many relationship whose related resources are summed over. + /// + public ResourceFieldChainExpression TargetToManyRelationship { get; } + + /// + /// The selector to apply on related resources, which can be a function or a field chain. Chain format: an optional list of to-one relationships, + /// followed by an attribute. The selector must return a numeric type. + /// + public QueryExpression Selector { get; } + + /// + /// The CLR type this function returns, which is always . + /// + public override Type ReturnType { get; } = typeof(ulong); + + public SumExpression(ResourceFieldChainExpression targetToManyRelationship, QueryExpression selector) + { + ArgumentNullException.ThrowIfNull(targetToManyRelationship); + ArgumentNullException.ThrowIfNull(selector); + + TargetToManyRelationship = targetToManyRelationship; + Selector = selector; + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.DefaultVisit(this, argument); + } + + public override string ToString() + { + return InnerToString(false); + } + + public override string ToFullString() + { + return InnerToString(true); + } + + private string InnerToString(bool toFullString) + { + var builder = new StringBuilder(); + + builder.Append(Keyword); + builder.Append('('); + builder.Append(toFullString ? TargetToManyRelationship.ToFullString() : TargetToManyRelationship); + builder.Append(','); + builder.Append(toFullString ? Selector.ToFullString() : Selector); + builder.Append(')'); + + return builder.ToString(); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj is null || GetType() != obj.GetType()) + { + return false; + } + + var other = (SumExpression)obj; + + return TargetToManyRelationship.Equals(other.TargetToManyRelationship) && Selector.Equals(other.Selector); + } + + public override int GetHashCode() + { + return HashCode.Combine(TargetToManyRelationship, Selector); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParseTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParseTests.cs new file mode 100644 index 0000000000..853ac7ac4d --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParseTests.cs @@ -0,0 +1,89 @@ +using System.ComponentModel.Design; +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCoreTests.UnitTests.QueryStringParameters; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.Sum; + +public sealed class SumFilterParseTests : BaseParseTests +{ + private readonly FilterQueryStringParameterReader _reader; + + public SumFilterParseTests() + { + using var serviceProvider = new ServiceContainer(); + var resourceFactory = new ResourceFactory(serviceProvider); + var scopeParser = new QueryStringParameterScopeParser(); + var valueParser = new SumFilterParser(resourceFactory); + + _reader = new FilterQueryStringParameterReader(scopeParser, valueParser, Request, ResourceGraph, Options); + } + + [Theory] + [InlineData("filter", "equals(sum^", "( expected.")] + [InlineData("filter", "equals(sum(^", "To-many relationship expected.")] + [InlineData("filter", "equals(sum(^ ", "Unexpected whitespace.")] + [InlineData("filter", "equals(sum(^)", "To-many relationship expected.")] + [InlineData("filter", "equals(sum(^'a')", "To-many relationship expected.")] + [InlineData("filter", "equals(sum(^null)", "To-many relationship expected.")] + [InlineData("filter", "equals(sum(^some)", "Field 'some' does not exist on resource type 'blogs'.")] + [InlineData("filter", "equals(sum(^title)", + "Field chain on resource type 'blogs' failed to match the pattern: a to-many relationship. " + + "To-many relationship on resource type 'blogs' expected.")] + [InlineData("filter", "equals(sum(posts^))", ", expected.")] + [InlineData("filter", "equals(sum(posts,^))", "Field name expected.")] + [InlineData("filter", "equals(sum(posts,author^))", + "Field chain on resource type 'blogPosts' failed to match the pattern: zero or more to-one relationships, followed by an attribute. " + + "To-one relationship or attribute on resource type 'webAccounts' expected.")] + [InlineData("filter", "equals(sum(posts,^url))", "Attribute of a numeric type expected.")] + [InlineData("filter", "equals(sum(posts,^has(labels)))", "Function that returns a numeric type expected.")] + public void Reader_Read_Fails(string parameterName, string parameterValue, string errorMessage) + { + // Arrange + var parameterValueSource = new MarkedText(parameterValue, '^'); + + // Act + Action action = () => _reader.Read(parameterName, parameterValueSource.Text); + + // Assert + InvalidQueryStringParameterException exception = action.Should().ThrowExactly().And; + + exception.ParameterName.Should().Be(parameterName); + exception.Errors.Should().HaveCount(1); + + ErrorObject error = exception.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"{errorMessage} {parameterValueSource}"); + error.Source.Should().NotBeNull(); + error.Source.Parameter.Should().Be(parameterName); + } + + [Theory] + [InlineData("filter", "has(posts,greaterThan(sum(comments,numStars),'5'))", null)] + [InlineData("filter[posts]", "equals(sum(comments,numStars),'11')", "posts")] + [InlineData("filter[posts]", "equals(sum(labels,count(posts)),'8')", "posts")] + public void Reader_Read_Succeeds(string parameterName, string parameterValue, string? scopeExpected) + { + // Act + _reader.Read(parameterName, parameterValue); + + IReadOnlyCollection constraints = _reader.GetConstraints(); + + // Assert + ResourceFieldChainExpression? scope = constraints.Select(expressionInScope => expressionInScope.Scope).Single(); + scope?.ToString().Should().Be(scopeExpected); + + QueryExpression value = constraints.Select(expressionInScope => expressionInScope.Expression).Single(); + value.ToString().Should().Be(parameterValue); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs new file mode 100644 index 0000000000..11cb521a07 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterParser.cs @@ -0,0 +1,108 @@ +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings.FieldChains; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.Sum; + +internal sealed class SumFilterParser(IResourceFactory resourceFactory) + : FilterParser(resourceFactory) +{ + private static readonly FieldChainPattern SingleToManyRelationshipChain = FieldChainPattern.Parse("M"); + + private static readonly HashSet NumericTypes = + [ + typeof(sbyte), + typeof(byte), + typeof(short), + typeof(ushort), + typeof(int), + typeof(uint), + typeof(long), + typeof(ulong), + typeof(float), + typeof(double), + typeof(decimal) + ]; + + protected override bool IsFunction(string name) + { + if (name == SumExpression.Keyword) + { + return true; + } + + return base.IsFunction(name); + } + + protected override FunctionExpression ParseFunction() + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text, Value: SumExpression.Keyword }) + { + return ParseSum(); + } + + return base.ParseFunction(); + } + + private SumExpression ParseSum() + { + EatText(SumExpression.Keyword); + EatSingleCharacterToken(TokenKind.OpenParen); + + ResourceFieldChainExpression targetToManyRelationshipChain = ParseFieldChain(SingleToManyRelationshipChain, FieldChainPatternMatchOptions.None, + ResourceTypeInScope, "To-many relationship expected."); + + EatSingleCharacterToken(TokenKind.Comma); + + QueryExpression selector = ParseSumSelectorInScope(targetToManyRelationshipChain); + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new SumExpression(targetToManyRelationshipChain, selector); + } + + private QueryExpression ParseSumSelectorInScope(ResourceFieldChainExpression targetChain) + { + var toManyRelationship = (HasManyAttribute)targetChain.Fields.Single(); + + using IDisposable scope = InScopeOfResourceType(toManyRelationship.RightType); + return ParseSumSelector(); + } + + private QueryExpression ParseSumSelector() + { + int position = GetNextTokenPositionOrEnd(); + + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text } && IsFunction(nextToken.Value!)) + { + FunctionExpression function = ParseFunction(); + + if (!IsNumericType(function.ReturnType)) + { + throw new QueryParseException("Function that returns a numeric type expected.", position); + } + + return function; + } + + ResourceFieldChainExpression fieldChain = ParseFieldChain(BuiltInPatterns.ToOneChainEndingInAttribute, FieldChainPatternMatchOptions.None, + ResourceTypeInScope, null); + + var attrAttribute = (AttrAttribute)fieldChain.Fields[^1]; + + if (!IsNumericType(attrAttribute.Property.PropertyType)) + { + throw new QueryParseException("Attribute of a numeric type expected.", position); + } + + return fieldChain; + } + + private static bool IsNumericType(Type type) + { + Type innerType = Nullable.GetUnderlyingType(type) ?? type; + return NumericTypes.Contains(innerType); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterTests.cs new file mode 100644 index 0000000000..0f25661029 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumFilterTests.cs @@ -0,0 +1,141 @@ +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.Queries.QueryableBuilding; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.Sum; + +public sealed class SumFilterTests : IClassFixture, QueryStringDbContext>> +{ + private readonly IntegrationTestContext, QueryStringDbContext> _testContext; + private readonly QueryStringFakers _fakers = new(); + + public SumFilterTests(IntegrationTestContext, QueryStringDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + + testContext.ConfigureServices(services => + { + services.AddTransient(); + services.AddTransient(); + }); + } + + [Fact] + public async Task Can_filter_sum_at_primary_endpoint() + { + // Arrange + List posts = _fakers.BlogPost.GenerateList(2); + + posts[0].Comments = _fakers.Comment.GenerateSet(2); + posts[0].Comments.ElementAt(0).NumStars = 0; + posts[0].Comments.ElementAt(1).NumStars = 1; + + posts[1].Comments = _fakers.Comment.GenerateSet(2); + posts[1].Comments.ElementAt(0).NumStars = 2; + posts[1].Comments.ElementAt(1).NumStars = 3; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Posts.AddRange(posts); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogPosts?filter=greaterThan(sum(comments,numStars),'4')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogPosts"); + responseDocument.Data.ManyValue[0].Id.Should().Be(posts[1].StringId); + } + + [Fact] + public async Task Can_filter_sum_on_count_at_secondary_endpoint() + { + // Arrange + List posts = _fakers.BlogPost.GenerateList(2); + + posts[0].Comments = _fakers.Comment.GenerateSet(2); + posts[0].Comments.ElementAt(0).NumStars = 1; + posts[0].Comments.ElementAt(1).NumStars = 1; + posts[0].Contributors = _fakers.Woman.GenerateSet(1); + + posts[1].Comments = _fakers.Comment.GenerateSet(2); + posts[1].Comments.ElementAt(0).NumStars = 2; + posts[1].Comments.ElementAt(1).NumStars = 2; + posts[1].Contributors = _fakers.Man.GenerateSet(2); + posts[1].Contributors.ElementAt(0).Children = _fakers.Woman.GenerateSet(3); + posts[1].Contributors.ElementAt(1).Children = _fakers.Man.GenerateSet(3); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Posts.AddRange(posts); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogPosts?filter=lessThan(sum(comments,numStars),sum(contributors,count(children)))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogPosts"); + responseDocument.Data.ManyValue[0].Id.Should().Be(posts[1].StringId); + } + + [Fact] + public async Task Can_filter_sum_in_included_resources() + { + // Arrange + Blog blog = _fakers.Blog.GenerateOne(); + blog.Posts = _fakers.BlogPost.GenerateList(2); + + blog.Posts[0].Comments = _fakers.Comment.GenerateSet(2); + blog.Posts[0].Comments.ElementAt(0).NumStars = 1; + blog.Posts[0].Comments.ElementAt(1).NumStars = 1; + + blog.Posts[1].Comments = _fakers.Comment.GenerateSet(2); + blog.Posts[1].Comments.ElementAt(0).NumStars = 1; + blog.Posts[1].Comments.ElementAt(1).NumStars = 2; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Blogs.Add(blog); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/blogs?include=posts&filter[posts]=equals(sum(comments,numStars),'3')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Type.Should().Be("blogs"); + responseDocument.Data.ManyValue[0].Id.Should().Be(blog.StringId); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("blogPosts"); + responseDocument.Included[0].Id.Should().Be(blog.Posts[1].StringId); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumWhereClauseBuilder.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumWhereClauseBuilder.cs new file mode 100644 index 0000000000..55b9102ff4 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/Sum/SumWhereClauseBuilder.cs @@ -0,0 +1,46 @@ +using System.Linq.Expressions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.QueryableBuilding; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.Sum; + +internal sealed class SumWhereClauseBuilder : WhereClauseBuilder +{ + public override Expression DefaultVisit(QueryExpression expression, QueryClauseBuilderContext context) + { + if (expression is SumExpression sumExpression) + { + return VisitSum(sumExpression, context); + } + + return base.DefaultVisit(expression, context); + } + + private MethodCallExpression VisitSum(SumExpression expression, QueryClauseBuilderContext context) + { + Expression collectionPropertyAccess = Visit(expression.TargetToManyRelationship, context); + + ResourceType selectorResourceType = ((HasManyAttribute)expression.TargetToManyRelationship.Fields.Single()).RightType; + using LambdaScope lambdaScope = context.LambdaScopeFactory.CreateScope(selectorResourceType.ClrType); + + var nestedContext = new QueryClauseBuilderContext(collectionPropertyAccess, selectorResourceType, typeof(Enumerable), context.EntityModel, + context.LambdaScopeFactory, lambdaScope, context.QueryableBuilder, context.State); + + LambdaExpression lambda = GetSelectorLambda(expression.Selector, nestedContext); + + return SumExtensionMethodCall(lambda, nestedContext); + } + + private LambdaExpression GetSelectorLambda(QueryExpression expression, QueryClauseBuilderContext context) + { + Expression body = Visit(expression, context); + return Expression.Lambda(body, context.LambdaScope.Parameter); + } + + private static MethodCallExpression SumExtensionMethodCall(LambdaExpression selector, QueryClauseBuilderContext context) + { + return Expression.Call(context.ExtensionType, "Sum", [context.LambdaScope.Parameter.Type], context.Source, selector); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/FilterRewritingResourceDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/FilterRewritingResourceDefinition.cs new file mode 100644 index 0000000000..e6578e5c0c --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/FilterRewritingResourceDefinition.cs @@ -0,0 +1,24 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.TimeOffset; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public class FilterRewritingResourceDefinition(IResourceGraph resourceGraph, TimeProvider timeProvider) + : JsonApiResourceDefinition(resourceGraph) + where TResource : class, IIdentifiable +{ + private readonly FilterTimeOffsetRewriter _rewriter = new(timeProvider); + + public override FilterExpression? OnApplyFilter(FilterExpression? existingFilter) + { + if (existingFilter != null) + { + return (FilterExpression)_rewriter.Visit(existingFilter, null)!; + } + + return existingFilter; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/FilterTimeOffsetRewriter.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/FilterTimeOffsetRewriter.cs new file mode 100644 index 0000000000..97c975a059 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/FilterTimeOffsetRewriter.cs @@ -0,0 +1,38 @@ +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.TimeOffset; + +internal sealed class FilterTimeOffsetRewriter(TimeProvider timeProvider) : QueryExpressionRewriter +{ + private static readonly Dictionary InverseComparisonOperatorTable = new() + { + [ComparisonOperator.GreaterThan] = ComparisonOperator.LessThan, + [ComparisonOperator.GreaterOrEqual] = ComparisonOperator.LessOrEqual, + [ComparisonOperator.Equals] = ComparisonOperator.Equals, + [ComparisonOperator.LessThan] = ComparisonOperator.GreaterThan, + [ComparisonOperator.LessOrEqual] = ComparisonOperator.GreaterOrEqual + }; + + private readonly TimeProvider _timeProvider = timeProvider; + + public override QueryExpression? VisitComparison(ComparisonExpression expression, object? argument) + { + if (expression.Right is TimeOffsetExpression timeOffset) + { + DateTime utcNow = _timeProvider.GetUtcNow().UtcDateTime; + + var offsetComparison = + new ComparisonExpression(timeOffset.Value < TimeSpan.Zero ? InverseComparisonOperatorTable[expression.Operator] : expression.Operator, + expression.Left, new LiteralConstantExpression(utcNow + timeOffset.Value)); + + ComparisonExpression? timeComparison = expression.Operator is ComparisonOperator.LessThan or ComparisonOperator.LessOrEqual + ? new ComparisonExpression(timeOffset.Value < TimeSpan.Zero ? ComparisonOperator.LessOrEqual : ComparisonOperator.GreaterOrEqual, + expression.Left, new LiteralConstantExpression(utcNow)) + : null; + + return timeComparison == null ? offsetComparison : new LogicalExpression(LogicalOperator.And, offsetComparison, timeComparison); + } + + return base.VisitComparison(expression, argument); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetExpression.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetExpression.cs new file mode 100644 index 0000000000..8229276acd --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetExpression.cs @@ -0,0 +1,91 @@ +using System.Text; +using JsonApiDotNetCore.Queries.Expressions; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.TimeOffset; + +/// +/// This expression wraps a time duration. It represents the "timeOffset" function, resulting from text such as: +/// +/// timeOffset('+0:10:00') +/// +/// , or: +/// +/// timeOffset('-0:10:00') +/// +/// . +/// +internal sealed class TimeOffsetExpression : FunctionExpression +{ + public const string Keyword = "timeOffset"; + + // Only used to show the original input in errors and diagnostics. Not part of the semantic expression value. + private readonly LiteralConstantExpression _timeSpanConstant; + + /// + /// The time offset, which can be negative. + /// + public TimeSpan Value { get; } + + /// + /// The CLR type this function returns, which is always . + /// + public override Type ReturnType { get; } = typeof(TimeSpan); + + public TimeOffsetExpression(LiteralConstantExpression timeSpanConstant) + { + ArgumentNullException.ThrowIfNull(timeSpanConstant); + + if (timeSpanConstant.TypedValue is not TimeSpan timeSpan) + { + throw new ArgumentException($"Constant must contain a {nameof(TimeSpan)}.", nameof(timeSpanConstant)); + } + + _timeSpanConstant = timeSpanConstant; + + Value = timeSpan; + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.DefaultVisit(this, argument); + } + + public override string ToString() + { + var builder = new StringBuilder(); + + builder.Append(Keyword); + builder.Append('('); + builder.Append(_timeSpanConstant); + builder.Append(')'); + + return builder.ToString(); + } + + public override string ToFullString() + { + return ToString(); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj is null || GetType() != obj.GetType()) + { + return false; + } + + var other = (TimeOffsetExpression)obj; + + return Value == other.Value; + } + + public override int GetHashCode() + { + return Value.GetHashCode(); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetFilterParser.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetFilterParser.cs new file mode 100644 index 0000000000..81d80f64b6 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetFilterParser.cs @@ -0,0 +1,86 @@ +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.TimeOffset; + +internal sealed class TimeOffsetFilterParser(IResourceFactory resourceFactory) + : FilterParser(resourceFactory) +{ + protected override bool IsFunction(string name) + { + if (name == TimeOffsetExpression.Keyword) + { + return true; + } + + return base.IsFunction(name); + } + + protected override FunctionExpression ParseFunction() + { + if (TokenStack.TryPeek(out Token? nextToken) && nextToken is { Kind: TokenKind.Text, Value: TimeOffsetExpression.Keyword }) + { + return ParseTimeOffset(); + } + + return base.ParseFunction(); + } + + private TimeOffsetExpression ParseTimeOffset() + { + EatText(TimeOffsetExpression.Keyword); + EatSingleCharacterToken(TokenKind.OpenParen); + + LiteralConstantExpression constant = ParseTimeSpanConstant(); + + EatSingleCharacterToken(TokenKind.CloseParen); + + return new TimeOffsetExpression(constant); + } + + private LiteralConstantExpression ParseTimeSpanConstant() + { + int position = GetNextTokenPositionOrEnd(); + + if (TokenStack.TryPop(out Token? token) && token.Kind == TokenKind.QuotedText) + { + string value = token.Value!; + + if (value.Length > 1 && value[0] is '+' or '-') + { + TimeSpan timeSpan = ConvertStringToTimeSpan(value[1..], position); + TimeSpan timeOffset = value[0] == '-' ? -timeSpan : timeSpan; + + return new LiteralConstantExpression(timeOffset, value); + } + } + + throw new QueryParseException("Time offset between quotes expected.", position); + } + + private static TimeSpan ConvertStringToTimeSpan(string value, int position) + { + try + { + return (TimeSpan)RuntimeTypeConverter.ConvertType(value, typeof(TimeSpan))!; + } + catch (FormatException exception) + { + throw new QueryParseException($"Failed to convert '{value}' of type 'String' to type '{nameof(TimeSpan)}'.", position, exception); + } + } + + protected override ComparisonExpression ParseComparison(string operatorName) + { + int position = GetNextTokenPositionOrEnd(); + ComparisonExpression comparison = base.ParseComparison(operatorName); + + if (comparison.Left is TimeOffsetExpression) + { + throw new QueryParseException("The 'timeOffset' function can only be used at the right side of comparisons.", position); + } + + return comparison; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetTests.cs new file mode 100644 index 0000000000..329af80475 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/CustomFunctions/TimeOffset/TimeOffsetTests.cs @@ -0,0 +1,232 @@ +using System.Net; +using FluentAssertions; +using Humanizer; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.CustomFunctions.TimeOffset; + +public sealed class TimeOffsetTests : IClassFixture, QueryStringDbContext>> +{ + private readonly IntegrationTestContext, QueryStringDbContext> _testContext; + private readonly QueryStringFakers _fakers = new(); + + public TimeOffsetTests(IntegrationTestContext, QueryStringDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + + testContext.ConfigureServices(services => + { + services.AddTransient(); + services.AddScoped(typeof(IResourceDefinition<,>), typeof(FilterRewritingResourceDefinition<,>)); + }); + } + + [Theory] + [InlineData("-0:10:00", ComparisonOperator.GreaterThan, "0")] // more than 10 minutes ago + [InlineData("-0:10:00", ComparisonOperator.GreaterOrEqual, "0,1")] // at least 10 minutes ago + [InlineData("-0:10:00", ComparisonOperator.Equals, "1")] // exactly 10 minutes ago + [InlineData("-0:10:00", ComparisonOperator.LessThan, "2,3")] // less than 10 minutes ago + [InlineData("-0:10:00", ComparisonOperator.LessOrEqual, "1,2,3")] // at most 10 minutes ago + [InlineData("+0:10:00", ComparisonOperator.GreaterThan, "6")] // more than 10 minutes in the future + [InlineData("+0:10:00", ComparisonOperator.GreaterOrEqual, "5,6")] // at least 10 minutes in the future + [InlineData("+0:10:00", ComparisonOperator.Equals, "5")] // in exactly 10 minutes + [InlineData("+0:10:00", ComparisonOperator.LessThan, "3,4")] // less than 10 minutes in the future + [InlineData("+0:10:00", ComparisonOperator.LessOrEqual, "3,4,5")] // at most 10 minutes in the future + public async Task Can_filter_comparison_on_relative_time(string filterValue, ComparisonOperator comparisonOperator, string matchingRowsExpected) + { + // Arrange + var timeProvider = _testContext.Factory.Services.GetRequiredService(); + DateTimeOffset utcNow = timeProvider.GetUtcNow(); + + List reminders = _fakers.Reminder.GenerateList(7); + reminders[0].RemindsAt = utcNow.Add(TimeSpan.FromMinutes(-15)).UtcDateTime; + reminders[1].RemindsAt = utcNow.Add(TimeSpan.FromMinutes(-10)).UtcDateTime; + reminders[2].RemindsAt = utcNow.Add(TimeSpan.FromMinutes(-5)).UtcDateTime; + reminders[3].RemindsAt = utcNow.Add(TimeSpan.FromMinutes(0)).UtcDateTime; + reminders[4].RemindsAt = utcNow.Add(TimeSpan.FromMinutes(5)).UtcDateTime; + reminders[5].RemindsAt = utcNow.Add(TimeSpan.FromMinutes(10)).UtcDateTime; + reminders[6].RemindsAt = utcNow.Add(TimeSpan.FromMinutes(15)).UtcDateTime; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Reminders.AddRange(reminders); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/reminders?filter={comparisonOperator.ToString().Camelize()}(remindsAt,timeOffset('{filterValue.Replace("+", "%2B")}'))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + int[] matchingRowIndices = matchingRowsExpected.Split(',').Select(int.Parse).ToArray(); + responseDocument.Data.ManyValue.Should().HaveCount(matchingRowIndices.Length); + + foreach (int rowIndex in matchingRowIndices) + { + responseDocument.Data.ManyValue.Should().ContainSingle(resource => resource.Id == reminders[rowIndex].StringId); + } + } + + [Fact] + public async Task Cannot_filter_comparison_on_missing_relative_time() + { + // Arrange + var parameterValue = new MarkedText("equals(remindsAt,timeOffset(^", '^'); + string route = $"/reminders?filter={parameterValue.Text}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"Time offset between quotes expected. {parameterValue}"); + error.Source.Should().NotBeNull(); + error.Source.Parameter.Should().Be("filter"); + } + + [Fact] + public async Task Cannot_filter_comparison_on_invalid_relative_time() + { + // Arrange + var parameterValue = new MarkedText("equals(remindsAt,timeOffset(^'-*'))", '^'); + string route = $"/reminders?filter={parameterValue.Text}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"Failed to convert '*' of type 'String' to type 'TimeSpan'. {parameterValue}"); + error.Source.Should().NotBeNull(); + error.Source.Parameter.Should().Be("filter"); + } + + [Fact] + public async Task Cannot_filter_comparison_on_relative_time_at_left_side() + { + // Arrange + var parameterValue = new MarkedText("^equals(timeOffset('-0:10:00'),remindsAt)", '^'); + string route = $"/reminders?filter={parameterValue.Text}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"The 'timeOffset' function can only be used at the right side of comparisons. {parameterValue}"); + error.Source.Should().NotBeNull(); + error.Source.Parameter.Should().Be("filter"); + } + + [Fact] + public async Task Cannot_filter_any_on_relative_time() + { + // Arrange + var parameterValue = new MarkedText("any(remindsAt,^timeOffset('-0:10:00'))", '^'); + string route = $"/reminders?filter={parameterValue.Text}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"Value between quotes expected. {parameterValue}"); + error.Source.Should().NotBeNull(); + error.Source.Parameter.Should().Be("filter"); + } + + [Fact] + public async Task Cannot_filter_text_match_on_relative_time() + { + // Arrange + var parameterValue = new MarkedText("startsWith(^remindsAt,timeOffset('-0:10:00'))", '^'); + string route = $"/reminders?filter={parameterValue.Text}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"Attribute of type 'String' expected. {parameterValue}"); + error.Source.Should().NotBeNull(); + error.Source.Parameter.Should().Be("filter"); + } + + [Fact] + public async Task Can_filter_comparison_on_relative_time_in_nested_expression() + { + // Arrange + var timeProvider = _testContext.Factory.Services.GetRequiredService(); + DateTimeOffset utcNow = timeProvider.GetUtcNow(); + + Calendar calendar = _fakers.Calendar.GenerateOne(); + calendar.Appointments = _fakers.Appointment.GenerateSet(2); + + calendar.Appointments.ElementAt(0).Reminders = _fakers.Reminder.GenerateList(1); + calendar.Appointments.ElementAt(0).Reminders[0].RemindsAt = utcNow.UtcDateTime; + + calendar.Appointments.ElementAt(1).Reminders = _fakers.Reminder.GenerateList(1); + calendar.Appointments.ElementAt(1).Reminders[0].RemindsAt = utcNow.Add(TimeSpan.FromMinutes(30)).UtcDateTime; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Calendars.Add(calendar); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/calendars/{calendar.StringId}/appointments?filter=has(reminders,equals(remindsAt,timeOffset('%2B0:30:00')))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + + responseDocument.Data.ManyValue[0].Id.Should().Be(calendar.Appointments.ElementAt(1).StringId); + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs index 402e6f86af..2022505e0d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDataTypeTests.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Net; using System.Reflection; using System.Text.Json.Serialization; @@ -60,7 +61,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); string attributeName = propertyName.Camelize(); - string route = $"/filterableResources?filter=equals({attributeName},'{propertyValue}')"; + string? attributeValue = Convert.ToString(propertyValue, CultureInfo.InvariantCulture); + + string route = $"/filterableResources?filter=equals({attributeName},'{attributeValue}')"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -68,8 +71,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey(attributeName).With(value => value.Should().Be(value)); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey(attributeName).WhoseValue.Should().Be(propertyValue); } [Fact] @@ -88,7 +91,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/filterableResources?filter=equals(someDecimal,'{resource.SomeDecimal}')"; + string route = $"/filterableResources?filter=equals(someDecimal,'{resource.SomeDecimal.ToString(CultureInfo.InvariantCulture)}')"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -96,8 +99,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someDecimal").With(value => value.Should().Be(resource.SomeDecimal)); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("someDecimal").WhoseValue.Should().Be(resource.SomeDecimal); } [Fact] @@ -124,8 +127,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someGuid").With(value => value.Should().Be(resource.SomeGuid)); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("someGuid").WhoseValue.Should().Be(resource.SomeGuid); } [Fact] @@ -152,10 +155,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someDateTimeInLocalZone") - .With(value => value.Should().Be(resource.SomeDateTimeInLocalZone)); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("someDateTimeInLocalZone").WhoseValue.Should().Be(resource.SomeDateTimeInLocalZone); } [Fact] @@ -182,10 +184,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someDateTimeInUtcZone") - .With(value => value.Should().Be(resource.SomeDateTimeInUtcZone)); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("someDateTimeInUtcZone").WhoseValue.Should().Be(resource.SomeDateTimeInUtcZone); } [Fact] @@ -212,8 +213,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someDateTimeOffset").With(value => value.Should().Be(resource.SomeDateTimeOffset)); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("someDateTimeOffset").WhoseValue.Should().Be(resource.SomeDateTimeOffset); } [Fact] @@ -232,7 +233,63 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/filterableResources?filter=equals(someTimeSpan,'{resource.SomeTimeSpan}')"; + string route = $"/filterableResources?filter=equals(someTimeSpan,'{resource.SomeTimeSpan:c}')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("someTimeSpan").WhoseValue.Should().Be(resource.SomeTimeSpan); + } + + [Fact] + public async Task Can_filter_equality_on_type_DateOnly() + { + // Arrange + var resource = new FilterableResource + { + SomeDateOnly = DateOnly.FromDateTime(27.January(2003)) + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.FilterableResources.AddRange(resource, new FilterableResource()); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/filterableResources?filter=equals(someDateOnly,'{resource.SomeDateOnly:O}')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("someDateOnly").WhoseValue.Should().Be(resource.SomeDateOnly); + } + + [Fact] + public async Task Can_filter_equality_on_type_TimeOnly() + { + // Arrange + var resource = new FilterableResource + { + SomeTimeOnly = new TimeOnly(23, 59, 59, 999) + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.FilterableResources.AddRange(resource, new FilterableResource()); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/filterableResources?filter=equals(someTimeOnly,'{resource.SomeTimeOnly:O}')"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -240,12 +297,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someTimeSpan").With(value => value.Should().Be(resource.SomeTimeSpan)); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("someTimeOnly").WhoseValue.Should().Be(resource.SomeTimeOnly); } [Fact] - public async Task Cannot_filter_equality_on_incompatible_value() + public async Task Cannot_filter_equality_on_incompatible_values() { // Arrange var resource = new FilterableResource @@ -260,7 +317,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - const string route = "/filterableResources?filter=equals(someInt32,'ABC')"; + var parameterValue = new MarkedText("equals(someInt32,^'ABC')", '^'); + string route = $"/filterableResources?filter={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -268,13 +326,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Query creation failed due to incompatible types."); - error.Detail.Should().Be("Failed to convert 'ABC' of type 'String' to type 'Int32'."); - error.Source.Should().BeNull(); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"Failed to convert 'ABC' of type 'String' to type 'Int32'. {parameterValue}"); + error.Source.Should().NotBeNull(); + error.Source.Parameter.Should().Be("filter"); } [Theory] @@ -288,6 +347,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => [InlineData(nameof(FilterableResource.SomeNullableDateTime))] [InlineData(nameof(FilterableResource.SomeNullableDateTimeOffset))] [InlineData(nameof(FilterableResource.SomeNullableTimeSpan))] + [InlineData(nameof(FilterableResource.SomeNullableDateOnly))] + [InlineData(nameof(FilterableResource.SomeNullableTimeOnly))] [InlineData(nameof(FilterableResource.SomeNullableEnum))] public async Task Can_filter_is_null_on_type(string propertyName) { @@ -308,6 +369,8 @@ public async Task Can_filter_is_null_on_type(string propertyName) SomeNullableDateTime = 1.January(2001).AsUtc(), SomeNullableDateTimeOffset = 1.January(2001).AsUtc(), SomeNullableTimeSpan = TimeSpan.FromHours(1), + SomeNullableDateOnly = DateOnly.FromDateTime(1.January(2001)), + SomeNullableTimeOnly = new TimeOnly(1, 0), SomeNullableEnum = DayOfWeek.Friday }; @@ -327,8 +390,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey(attributeName).With(value => value.Should().BeNull()); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey(attributeName).WhoseValue.Should().BeNull(); } [Theory] @@ -342,6 +405,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => [InlineData(nameof(FilterableResource.SomeNullableDateTime))] [InlineData(nameof(FilterableResource.SomeNullableDateTimeOffset))] [InlineData(nameof(FilterableResource.SomeNullableTimeSpan))] + [InlineData(nameof(FilterableResource.SomeNullableDateOnly))] + [InlineData(nameof(FilterableResource.SomeNullableTimeOnly))] [InlineData(nameof(FilterableResource.SomeNullableEnum))] public async Task Can_filter_is_not_null_on_type(string propertyName) { @@ -358,6 +423,8 @@ public async Task Can_filter_is_not_null_on_type(string propertyName) SomeNullableDateTime = 1.January(2001).AsUtc(), SomeNullableDateTimeOffset = 1.January(2001).AsUtc(), SomeNullableTimeSpan = TimeSpan.FromHours(1), + SomeNullableDateOnly = DateOnly.FromDateTime(1.January(2001)), + SomeNullableTimeOnly = new TimeOnly(1, 0), SomeNullableEnum = DayOfWeek.Friday }; @@ -377,7 +444,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey(attributeName).With(value => value.Should().NotBeNull()); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey(attributeName).WhoseValue.Should().NotBeNull(); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDbContext.cs index 1c6c7eff32..9b57bd9d50 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDbContext.cs @@ -1,24 +1,23 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; // @formatter:wrap_chained_method_calls chop_always namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.Filtering; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class FilterDbContext : DbContext +public sealed class FilterDbContext(DbContextOptions options) + : TestableDbContext(options) { public DbSet FilterableResources => Set(); - public FilterDbContext(DbContextOptions options) - : base(options) - { - } - protected override void OnModelCreating(ModelBuilder builder) { builder.Entity() .Property(resource => resource.SomeDateTimeInLocalZone) .HasColumnType("timestamp without time zone"); + + base.OnModelCreating(builder); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs index 1d4e1793d9..cf8f696929 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterDepthTests.cs @@ -11,6 +11,8 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.Filtering; public sealed class FilterDepthTests : IClassFixture, QueryStringDbContext>> { + private const string CollectionErrorMessage = "This query string parameter can only be used on a collection of resources (not on a single resource)."; + private readonly IntegrationTestContext, QueryStringDbContext> _testContext; private readonly QueryStringFakers _fakers = new(); @@ -29,7 +31,7 @@ public FilterDepthTests(IntegrationTestContext posts = _fakers.BlogPost.Generate(2); + List posts = _fakers.BlogPost.GenerateList(2); posts[0].Caption = "One"; posts[1].Caption = "Two"; @@ -48,15 +50,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(posts[1].StringId); } [Fact] - public async Task Cannot_filter_in_single_primary_resource() + public async Task Cannot_filter_in_primary_resource() { // Arrange - BlogPost post = _fakers.BlogPost.Generate(); + BlogPost post = _fakers.BlogPost.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -72,13 +74,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified filter is invalid."); - error.Detail.Should().Be("This query string parameter can only be used on a collection of resources (not on a single resource)."); - error.Source.ShouldNotBeNull(); + error.Detail.Should().Be($"{CollectionErrorMessage} Failed at position 1: ^filter"); + error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("filter"); } @@ -86,8 +88,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_filter_in_secondary_resources() { // Arrange - Blog blog = _fakers.Blog.Generate(); - blog.Posts = _fakers.BlogPost.Generate(2); + Blog blog = _fakers.Blog.GenerateOne(); + blog.Posts = _fakers.BlogPost.GenerateList(2); blog.Posts[0].Caption = "One"; blog.Posts[1].Caption = "Two"; @@ -105,15 +107,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(blog.Posts[1].StringId); } [Fact] - public async Task Cannot_filter_in_single_secondary_resource() + public async Task Cannot_filter_in_secondary_resource() { // Arrange - BlogPost post = _fakers.BlogPost.Generate(); + BlogPost post = _fakers.BlogPost.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -129,13 +131,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified filter is invalid."); - error.Detail.Should().Be("This query string parameter can only be used on a collection of resources (not on a single resource)."); - error.Source.ShouldNotBeNull(); + error.Detail.Should().Be($"{CollectionErrorMessage} Failed at position 1: ^filter"); + error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("filter"); } @@ -143,10 +145,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_filter_on_ManyToOne_relationship() { // Arrange - List posts = _fakers.BlogPost.Generate(3); - posts[0].Author = _fakers.WebAccount.Generate(); + List posts = _fakers.BlogPost.GenerateList(3); + posts[0].Author = _fakers.WebAccount.GenerateOne(); posts[0].Author!.UserName = "Conner"; - posts[1].Author = _fakers.WebAccount.Generate(); + posts[1].Author = _fakers.WebAccount.GenerateOne(); posts[1].Author!.UserName = "Smith"; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -164,11 +166,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue.Should().HaveCount(2); responseDocument.Data.ManyValue.Should().ContainSingle(post => post.Id == posts[1].StringId); responseDocument.Data.ManyValue.Should().ContainSingle(post => post.Id == posts[2].StringId); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Id.Should().Be(posts[1].Author!.StringId); } @@ -176,8 +178,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_filter_on_OneToMany_relationship() { // Arrange - List blogs = _fakers.Blog.Generate(2); - blogs[1].Posts = _fakers.BlogPost.Generate(1); + List blogs = _fakers.Blog.GenerateList(2); + blogs[1].Posts = _fakers.BlogPost.GenerateList(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -194,7 +196,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); } @@ -202,10 +204,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_filter_on_OneToMany_relationship_with_nested_condition() { // Arrange - List blogs = _fakers.Blog.Generate(2); - blogs[0].Posts = _fakers.BlogPost.Generate(1); - blogs[1].Posts = _fakers.BlogPost.Generate(1); - blogs[1].Posts[0].Comments = _fakers.Comment.Generate(1).ToHashSet(); + List blogs = _fakers.Blog.GenerateList(2); + blogs[0].Posts = _fakers.BlogPost.GenerateList(1); + blogs[1].Posts = _fakers.BlogPost.GenerateList(1); + blogs[1].Posts[0].Comments = _fakers.Comment.GenerateSet(1); blogs[1].Posts[0].Comments.ElementAt(0).Text = "ABC"; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -223,7 +225,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); } @@ -231,8 +233,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_filter_on_ManyToMany_relationship() { // Arrange - List posts = _fakers.BlogPost.Generate(2); - posts[1].Labels = _fakers.Label.Generate(1).ToHashSet(); + List posts = _fakers.BlogPost.GenerateList(2); + posts[1].Labels = _fakers.Label.GenerateSet(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -249,7 +251,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(posts[1].StringId); } @@ -257,12 +259,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_filter_on_ManyToMany_relationship_with_nested_condition() { // Arrange - List blogs = _fakers.Blog.Generate(2); + List blogs = _fakers.Blog.GenerateList(2); - blogs[0].Posts = _fakers.BlogPost.Generate(1); + blogs[0].Posts = _fakers.BlogPost.GenerateList(1); - blogs[1].Posts = _fakers.BlogPost.Generate(1); - blogs[1].Posts[0].Labels = _fakers.Label.Generate(1).ToHashSet(); + blogs[1].Posts = _fakers.BlogPost.GenerateList(1); + blogs[1].Posts[0].Labels = _fakers.Label.GenerateSet(1); blogs[1].Posts[0].Labels.ElementAt(0).Color = LabelColor.Green; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -280,7 +282,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); } @@ -288,8 +290,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_filter_in_scope_of_OneToMany_relationship() { // Arrange - Blog blog = _fakers.Blog.Generate(); - blog.Posts = _fakers.BlogPost.Generate(2); + Blog blog = _fakers.Blog.GenerateOne(); + blog.Posts = _fakers.BlogPost.GenerateList(2); blog.Posts[0].Caption = "One"; blog.Posts[1].Caption = "Two"; @@ -308,19 +310,19 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Id.Should().Be(blog.Posts[1].StringId); } [Fact] - public async Task Can_filter_in_scope_of_OneToMany_relationship_on_secondary_endpoint() + public async Task Can_filter_in_scope_of_OneToMany_relationship_at_secondary_endpoint() { // Arrange - Blog blog = _fakers.Blog.Generate(); - blog.Owner = _fakers.WebAccount.Generate(); - blog.Owner.Posts = _fakers.BlogPost.Generate(2); + Blog blog = _fakers.Blog.GenerateOne(); + blog.Owner = _fakers.WebAccount.GenerateOne(); + blog.Owner.Posts = _fakers.BlogPost.GenerateList(2); blog.Owner.Posts[0].Caption = "One"; blog.Owner.Posts[1].Caption = "Two"; @@ -338,9 +340,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Id.Should().Be(blog.Owner.Posts[1].StringId); } @@ -348,12 +350,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_filter_in_scope_of_ManyToMany_relationship() { // Arrange - List posts = _fakers.BlogPost.Generate(2); + List posts = _fakers.BlogPost.GenerateList(2); - posts[0].Labels = _fakers.Label.Generate(1).ToHashSet(); + posts[0].Labels = _fakers.Label.GenerateSet(1); posts[0].Labels.ElementAt(0).Name = "Cold"; - posts[1].Labels = _fakers.Label.Generate(1).ToHashSet(); + posts[1].Labels = _fakers.Label.GenerateSet(1); posts[1].Labels.ElementAt(0).Name = "Hot"; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -371,9 +373,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue.Should().HaveCount(2); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Id.Should().Be(posts[1].Labels.First().StringId); } @@ -381,9 +383,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_filter_in_scope_of_relationship_chain() { // Arrange - Blog blog = _fakers.Blog.Generate(); - blog.Owner = _fakers.WebAccount.Generate(); - blog.Owner.Posts = _fakers.BlogPost.Generate(2); + Blog blog = _fakers.Blog.GenerateOne(); + blog.Owner = _fakers.WebAccount.GenerateOne(); + blog.Owner.Posts = _fakers.BlogPost.GenerateList(2); blog.Owner.Posts[0].Caption = "One"; blog.Owner.Posts[1].Caption = "Two"; @@ -402,9 +404,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Included.ShouldHaveCount(2); + responseDocument.Included.Should().HaveCount(2); responseDocument.Included[0].Type.Should().Be("webAccounts"); responseDocument.Included[0].Id.Should().Be(blog.Owner.StringId); @@ -417,7 +419,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_filter_in_same_scope_multiple_times() { // Arrange - List posts = _fakers.BlogPost.Generate(3); + List posts = _fakers.BlogPost.GenerateList(3); posts[0].Caption = "One"; posts[1].Caption = "Two"; posts[2].Caption = "Three"; @@ -437,7 +439,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue.Should().HaveCount(2); responseDocument.Data.ManyValue[0].Id.Should().Be(posts[0].StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(posts[2].StringId); } @@ -449,10 +451,10 @@ public async Task Can_filter_in_same_scope_multiple_times_using_legacy_notation( var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); options.EnableLegacyFilterNotation = true; - List posts = _fakers.BlogPost.Generate(3); - posts[0].Author = _fakers.WebAccount.Generate(); - posts[1].Author = _fakers.WebAccount.Generate(); - posts[2].Author = _fakers.WebAccount.Generate(); + List posts = _fakers.BlogPost.GenerateList(3); + posts[0].Author = _fakers.WebAccount.GenerateOne(); + posts[1].Author = _fakers.WebAccount.GenerateOne(); + posts[2].Author = _fakers.WebAccount.GenerateOne(); posts[0].Author!.UserName = "Joe"; posts[0].Author!.DisplayName = "Smith"; @@ -478,7 +480,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue.Should().HaveCount(2); responseDocument.Data.ManyValue[0].Id.Should().Be(posts[0].StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(posts[1].StringId); } @@ -487,14 +489,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_filter_in_multiple_scopes() { // Arrange - List blogs = _fakers.Blog.Generate(2); + List blogs = _fakers.Blog.GenerateList(2); blogs[1].Title = "Technology"; - blogs[1].Owner = _fakers.WebAccount.Generate(); + blogs[1].Owner = _fakers.WebAccount.GenerateOne(); blogs[1].Owner!.UserName = "Smith"; - blogs[1].Owner!.Posts = _fakers.BlogPost.Generate(2); + blogs[1].Owner!.Posts = _fakers.BlogPost.GenerateList(2); blogs[1].Owner!.Posts[0].Caption = "One"; blogs[1].Owner!.Posts[1].Caption = "Two"; - blogs[1].Owner!.Posts[1].Comments = _fakers.Comment.Generate(2).ToHashSet(); + blogs[1].Owner!.Posts[1].Comments = _fakers.Comment.GenerateSet(2); blogs[1].Owner!.Posts[1].Comments.ElementAt(0).CreatedAt = 1.January(2000).AsUtc(); blogs[1].Owner!.Posts[1].Comments.ElementAt(1).CreatedAt = 10.January(2010).AsUtc(); @@ -520,10 +522,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); - responseDocument.Included.ShouldHaveCount(3); + responseDocument.Included.Should().HaveCount(3); responseDocument.Included[0].Type.Should().Be("webAccounts"); responseDocument.Included[0].Id.Should().Be(blogs[1].Owner!.StringId); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs index 0abad96024..791c5ad18b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterOperatorTests.cs @@ -15,6 +15,38 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings.Filtering; public sealed class FilterOperatorTests : IClassFixture, FilterDbContext>> { + private const string IntLowerBound = "19"; + private const string IntInTheRange = "20"; + private const string IntUpperBound = "21"; + + private const string DoubleLowerBound = "1.9"; + private const string DoubleInTheRange = "2.0"; + private const string DoubleUpperBound = "2.1"; + + private const string IsoDateTimeLowerBound = "2000-11-22T09:48:17"; + private const string IsoDateTimeInTheRange = "2000-11-22T12:34:56"; + private const string IsoDateTimeUpperBound = "2000-11-22T18:47:32"; + + private const string InvariantDateTimeLowerBound = "11/22/2000 9:48:17"; + private const string InvariantDateTimeInTheRange = "11/22/2000 12:34:56"; + private const string InvariantDateTimeUpperBound = "11/22/2000 18:47:32"; + + private const string TimeSpanLowerBound = "2:15:28:54.997"; + private const string TimeSpanInTheRange = "2:15:51:42.397"; + private const string TimeSpanUpperBound = "2:16:22:41.736"; + + private const string IsoDateOnlyLowerBound = "2000-10-22"; + private const string IsoDateOnlyInTheRange = "2000-11-22"; + private const string IsoDateOnlyUpperBound = "2000-12-22"; + + private const string InvariantDateOnlyLowerBound = "10/22/2000"; + private const string InvariantDateOnlyInTheRange = "11/22/2000"; + private const string InvariantDateOnlyUpperBound = "12/22/2000"; + + private const string TimeOnlyLowerBound = "15:28:54.997"; + private const string TimeOnlyInTheRange = "15:51:42.397"; + private const string TimeOnlyUpperBound = "16:22:41.736"; + private readonly IntegrationTestContext, FilterDbContext> _testContext; public FilterOperatorTests(IntegrationTestContext, FilterDbContext> testContext) @@ -51,8 +83,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someString").With(value => value.Should().Be(resource.SomeString)); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("someString").WhoseValue.Should().Be(resource.SomeString); + + responseDocument.Links.Should().NotBeNull(); + responseDocument.Links.Self.Should().Be("http://localhost/filterableResources?filter=equals(someString,'This%2c+that+%26+more+%2b+some')"); + responseDocument.Links.First.Should().Be("http://localhost/filterableResources?filter=equals(someString,%27This,%20that%20%26%20more%20%2B%20some%27)"); } [Fact] @@ -86,9 +122,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someInt32").With(value => value.Should().Be(resource.SomeInt32)); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("otherInt32").With(value => value.Should().Be(resource.OtherInt32)); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("someInt32").WhoseValue.Should().Be(resource.SomeInt32); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("otherInt32").WhoseValue.Should().Be(resource.OtherInt32); } [Fact] @@ -122,9 +158,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someNullableInt32").With(value => value.Should().Be(resource.SomeNullableInt32)); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("otherNullableInt32").With(value => value.Should().Be(resource.OtherNullableInt32)); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("someNullableInt32").WhoseValue.Should().Be(resource.SomeNullableInt32); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("otherNullableInt32").WhoseValue.Should().Be(resource.OtherNullableInt32); } [Fact] @@ -158,9 +194,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someInt32").With(value => value.Should().Be(resource.SomeInt32)); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someNullableInt32").With(value => value.Should().Be(resource.SomeNullableInt32)); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("someInt32").WhoseValue.Should().Be(resource.SomeInt32); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("someNullableInt32").WhoseValue.Should().Be(resource.SomeNullableInt32); } [Fact] @@ -194,9 +230,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someInt32").With(value => value.Should().Be(resource.SomeInt32)); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someNullableInt32").With(value => value.Should().Be(resource.SomeNullableInt32)); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("someInt32").WhoseValue.Should().Be(resource.SomeInt32); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("someNullableInt32").WhoseValue.Should().Be(resource.SomeNullableInt32); } [Fact] @@ -230,9 +266,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someInt32").With(value => value.Should().Be(resource.SomeInt32)); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someUnsignedInt64").With(value => value.Should().Be(resource.SomeUnsignedInt64)); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("someInt32").WhoseValue.Should().Be(resource.SomeInt32); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("someUnsignedInt64").WhoseValue.Should().Be(resource.SomeUnsignedInt64); } [Fact] @@ -247,7 +283,7 @@ public async Task Cannot_filter_equality_on_two_attributes_of_incompatible_types // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); @@ -257,25 +293,26 @@ public async Task Cannot_filter_equality_on_two_attributes_of_incompatible_types } [Theory] - [InlineData(19, 21, ComparisonOperator.LessThan, 20)] - [InlineData(19, 21, ComparisonOperator.LessThan, 21)] - [InlineData(19, 21, ComparisonOperator.LessOrEqual, 20)] - [InlineData(19, 21, ComparisonOperator.LessOrEqual, 19)] - [InlineData(21, 19, ComparisonOperator.GreaterThan, 20)] - [InlineData(21, 19, ComparisonOperator.GreaterThan, 19)] - [InlineData(21, 19, ComparisonOperator.GreaterOrEqual, 20)] - [InlineData(21, 19, ComparisonOperator.GreaterOrEqual, 21)] - public async Task Can_filter_comparison_on_whole_number(int matchingValue, int nonMatchingValue, ComparisonOperator filterOperator, double filterValue) + [InlineData(IntLowerBound, IntUpperBound, ComparisonOperator.LessThan, IntInTheRange)] + [InlineData(IntLowerBound, IntUpperBound, ComparisonOperator.LessThan, IntUpperBound)] + [InlineData(IntLowerBound, IntUpperBound, ComparisonOperator.LessOrEqual, IntInTheRange)] + [InlineData(IntLowerBound, IntUpperBound, ComparisonOperator.LessOrEqual, IntLowerBound)] + [InlineData(IntUpperBound, IntLowerBound, ComparisonOperator.GreaterThan, IntInTheRange)] + [InlineData(IntUpperBound, IntLowerBound, ComparisonOperator.GreaterThan, IntLowerBound)] + [InlineData(IntUpperBound, IntLowerBound, ComparisonOperator.GreaterOrEqual, IntInTheRange)] + [InlineData(IntUpperBound, IntLowerBound, ComparisonOperator.GreaterOrEqual, IntUpperBound)] + public async Task Can_filter_comparison_on_whole_number(string matchingValue, string nonMatchingValue, ComparisonOperator filterOperator, + string filterValue) { // Arrange var resource = new FilterableResource { - SomeInt32 = matchingValue + SomeInt32 = int.Parse(matchingValue, CultureInfo.InvariantCulture) }; var otherResource = new FilterableResource { - SomeInt32 = nonMatchingValue + SomeInt32 = int.Parse(nonMatchingValue, CultureInfo.InvariantCulture) }; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -293,31 +330,31 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someInt32").With(value => value.Should().Be(resource.SomeInt32)); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("someInt32").WhoseValue.Should().Be(resource.SomeInt32); } [Theory] - [InlineData(1.9, 2.1, ComparisonOperator.LessThan, 2.0)] - [InlineData(1.9, 2.1, ComparisonOperator.LessThan, 2.1)] - [InlineData(1.9, 2.1, ComparisonOperator.LessOrEqual, 2.0)] - [InlineData(1.9, 2.1, ComparisonOperator.LessOrEqual, 1.9)] - [InlineData(2.1, 1.9, ComparisonOperator.GreaterThan, 2.0)] - [InlineData(2.1, 1.9, ComparisonOperator.GreaterThan, 1.9)] - [InlineData(2.1, 1.9, ComparisonOperator.GreaterOrEqual, 2.0)] - [InlineData(2.1, 1.9, ComparisonOperator.GreaterOrEqual, 2.1)] - public async Task Can_filter_comparison_on_fractional_number(double matchingValue, double nonMatchingValue, ComparisonOperator filterOperator, - double filterValue) + [InlineData(DoubleLowerBound, DoubleUpperBound, ComparisonOperator.LessThan, DoubleInTheRange)] + [InlineData(DoubleLowerBound, DoubleUpperBound, ComparisonOperator.LessThan, DoubleUpperBound)] + [InlineData(DoubleLowerBound, DoubleUpperBound, ComparisonOperator.LessOrEqual, DoubleInTheRange)] + [InlineData(DoubleLowerBound, DoubleUpperBound, ComparisonOperator.LessOrEqual, DoubleLowerBound)] + [InlineData(DoubleUpperBound, DoubleLowerBound, ComparisonOperator.GreaterThan, DoubleInTheRange)] + [InlineData(DoubleUpperBound, DoubleLowerBound, ComparisonOperator.GreaterThan, DoubleLowerBound)] + [InlineData(DoubleUpperBound, DoubleLowerBound, ComparisonOperator.GreaterOrEqual, DoubleInTheRange)] + [InlineData(DoubleUpperBound, DoubleLowerBound, ComparisonOperator.GreaterOrEqual, DoubleUpperBound)] + public async Task Can_filter_comparison_on_fractional_number(string matchingValue, string nonMatchingValue, ComparisonOperator filterOperator, + string filterValue) { // Arrange var resource = new FilterableResource { - SomeDouble = matchingValue + SomeDouble = double.Parse(matchingValue, CultureInfo.InvariantCulture) }; var otherResource = new FilterableResource { - SomeDouble = nonMatchingValue + SomeDouble = double.Parse(nonMatchingValue, CultureInfo.InvariantCulture) }; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -335,31 +372,90 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someDouble").With(value => value.Should().Be(resource.SomeDouble)); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("someDouble").WhoseValue.Should().Be(resource.SomeDouble); + } + + [Theory] + [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessThan, IsoDateTimeInTheRange)] + [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessThan, IsoDateTimeUpperBound)] + [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessOrEqual, IsoDateTimeInTheRange)] + [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessOrEqual, IsoDateTimeLowerBound)] + [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterThan, IsoDateTimeInTheRange)] + [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterThan, IsoDateTimeLowerBound)] + [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, IsoDateTimeInTheRange)] + [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, IsoDateTimeUpperBound)] + [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessThan, InvariantDateTimeInTheRange)] + [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessThan, InvariantDateTimeUpperBound)] + [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessOrEqual, InvariantDateTimeInTheRange)] + [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessOrEqual, InvariantDateTimeLowerBound)] + [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterThan, InvariantDateTimeInTheRange)] + [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterThan, InvariantDateTimeLowerBound)] + [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, InvariantDateTimeInTheRange)] + [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, InvariantDateTimeUpperBound)] + public async Task Can_filter_comparison_on_DateTime_in_local_time_zone(string matchingValue, string nonMatchingValue, ComparisonOperator filterOperator, + string filterValue) + { + // Arrange + var resource = new FilterableResource + { + SomeDateTimeInLocalZone = DateTime.Parse(matchingValue, CultureInfo.InvariantCulture).AsLocal() + }; + + var otherResource = new FilterableResource + { + SomeDateTimeInLocalZone = DateTime.Parse(nonMatchingValue, CultureInfo.InvariantCulture).AsLocal() + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.FilterableResources.AddRange(resource, otherResource); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/filterableResources?filter={filterOperator.ToString().Camelize()}(someDateTimeInLocalZone,'{filterValue}')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("someDateTimeInLocalZone").WhoseValue.Should().Be(resource.SomeDateTimeInLocalZone); } [Theory] - [InlineData("2001-01-01", "2001-01-09", ComparisonOperator.LessThan, "2001-01-05")] - [InlineData("2001-01-01", "2001-01-09", ComparisonOperator.LessThan, "2001-01-09")] - [InlineData("2001-01-01", "2001-01-09", ComparisonOperator.LessOrEqual, "2001-01-05")] - [InlineData("2001-01-01", "2001-01-09", ComparisonOperator.LessOrEqual, "2001-01-01")] - [InlineData("2001-01-09", "2001-01-01", ComparisonOperator.GreaterThan, "2001-01-05")] - [InlineData("2001-01-09", "2001-01-01", ComparisonOperator.GreaterThan, "2001-01-01")] - [InlineData("2001-01-09", "2001-01-01", ComparisonOperator.GreaterOrEqual, "2001-01-05")] - [InlineData("2001-01-09", "2001-01-01", ComparisonOperator.GreaterOrEqual, "2001-01-09")] - public async Task Can_filter_comparison_on_DateTime_in_local_time_zone(string matchingDateTime, string nonMatchingDateTime, - ComparisonOperator filterOperator, string filterDateTime) + [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessThan, IsoDateTimeInTheRange)] + [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessThan, IsoDateTimeUpperBound)] + [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessOrEqual, IsoDateTimeInTheRange)] + [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessOrEqual, IsoDateTimeLowerBound)] + [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterThan, IsoDateTimeInTheRange)] + [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterThan, IsoDateTimeLowerBound)] + [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, IsoDateTimeInTheRange)] + [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, IsoDateTimeUpperBound)] + [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessThan, InvariantDateTimeInTheRange)] + [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessThan, InvariantDateTimeUpperBound)] + [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessOrEqual, InvariantDateTimeInTheRange)] + [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessOrEqual, InvariantDateTimeLowerBound)] + [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterThan, InvariantDateTimeInTheRange)] + [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterThan, InvariantDateTimeLowerBound)] + [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, InvariantDateTimeInTheRange)] + [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, InvariantDateTimeUpperBound)] + public async Task Can_filter_comparison_on_DateTime_in_UTC_time_zone(string matchingValue, string nonMatchingValue, ComparisonOperator filterOperator, + string filterValue) { // Arrange var resource = new FilterableResource { - SomeDateTimeInLocalZone = DateTime.Parse(matchingDateTime, CultureInfo.InvariantCulture).AsLocal() + SomeDateTimeInUtcZone = DateTime.Parse(matchingValue, CultureInfo.InvariantCulture).AsUtc() }; var otherResource = new FilterableResource { - SomeDateTimeInLocalZone = DateTime.Parse(nonMatchingDateTime, CultureInfo.InvariantCulture).AsLocal() + SomeDateTimeInUtcZone = DateTime.Parse(nonMatchingValue, CultureInfo.InvariantCulture).AsUtc() }; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -369,7 +465,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/filterableResources?filter={filterOperator.ToString().Camelize()}(someDateTimeInLocalZone,'{filterDateTime}')"; + string route = $"/filterableResources?filter={filterOperator.ToString().Camelize()}(someDateTimeInUtcZone,'{filterValue}Z')"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -377,33 +473,40 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someDateTimeInLocalZone") - .With(value => value.Should().Be(resource.SomeDateTimeInLocalZone)); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("someDateTimeInUtcZone").WhoseValue.Should().Be(resource.SomeDateTimeInUtcZone); } [Theory] - [InlineData("2001-01-01", "2001-01-09", ComparisonOperator.LessThan, "2001-01-05Z")] - [InlineData("2001-01-01", "2001-01-09", ComparisonOperator.LessThan, "2001-01-09Z")] - [InlineData("2001-01-01", "2001-01-09", ComparisonOperator.LessOrEqual, "2001-01-05Z")] - [InlineData("2001-01-01", "2001-01-09", ComparisonOperator.LessOrEqual, "2001-01-01Z")] - [InlineData("2001-01-09", "2001-01-01", ComparisonOperator.GreaterThan, "2001-01-05Z")] - [InlineData("2001-01-09", "2001-01-01", ComparisonOperator.GreaterThan, "2001-01-01Z")] - [InlineData("2001-01-09", "2001-01-01", ComparisonOperator.GreaterOrEqual, "2001-01-05Z")] - [InlineData("2001-01-09", "2001-01-01", ComparisonOperator.GreaterOrEqual, "2001-01-09Z")] - public async Task Can_filter_comparison_on_DateTime_in_UTC_time_zone(string matchingDateTime, string nonMatchingDateTime, ComparisonOperator filterOperator, - string filterDateTime) + [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessThan, IsoDateTimeInTheRange)] + [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessThan, IsoDateTimeUpperBound)] + [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessOrEqual, IsoDateTimeInTheRange)] + [InlineData(IsoDateTimeLowerBound, IsoDateTimeUpperBound, ComparisonOperator.LessOrEqual, IsoDateTimeLowerBound)] + [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterThan, IsoDateTimeInTheRange)] + [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterThan, IsoDateTimeLowerBound)] + [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, IsoDateTimeInTheRange)] + [InlineData(IsoDateTimeUpperBound, IsoDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, IsoDateTimeUpperBound)] + [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessThan, InvariantDateTimeInTheRange)] + [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessThan, InvariantDateTimeUpperBound)] + [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessOrEqual, InvariantDateTimeInTheRange)] + [InlineData(InvariantDateTimeLowerBound, InvariantDateTimeUpperBound, ComparisonOperator.LessOrEqual, InvariantDateTimeLowerBound)] + [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterThan, InvariantDateTimeInTheRange)] + [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterThan, InvariantDateTimeLowerBound)] + [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, InvariantDateTimeInTheRange)] + [InlineData(InvariantDateTimeUpperBound, InvariantDateTimeLowerBound, ComparisonOperator.GreaterOrEqual, InvariantDateTimeUpperBound)] + public async Task Can_filter_comparison_on_DateTimeOffset(string matchingValue, string nonMatchingValue, ComparisonOperator filterOperator, + string filterValue) { // Arrange var resource = new FilterableResource { - SomeDateTimeInUtcZone = DateTime.Parse(matchingDateTime, CultureInfo.InvariantCulture).AsUtc() + SomeDateTimeOffset = DateTime.Parse(matchingValue, CultureInfo.InvariantCulture).AsUtc() }; var otherResource = new FilterableResource { - SomeDateTimeInUtcZone = DateTime.Parse(nonMatchingDateTime, CultureInfo.InvariantCulture).AsUtc() + SomeDateTimeOffset = DateTime.Parse(nonMatchingValue, CultureInfo.InvariantCulture).AsUtc() }; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -413,7 +516,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/filterableResources?filter={filterOperator.ToString().Camelize()}(someDateTimeInUtcZone,'{filterDateTime}')"; + string route = $"/filterableResources?filter={filterOperator.ToString().Camelize()}(someDateTimeOffset,'{filterValue}Z')"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -421,10 +524,140 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); + + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("someDateTimeOffset").WhoseValue.Should().Be(resource.SomeDateTimeOffset); + } + + [Theory] + [InlineData(TimeSpanLowerBound, TimeSpanUpperBound, ComparisonOperator.LessThan, TimeSpanInTheRange)] + [InlineData(TimeSpanLowerBound, TimeSpanUpperBound, ComparisonOperator.LessThan, TimeSpanUpperBound)] + [InlineData(TimeSpanLowerBound, TimeSpanUpperBound, ComparisonOperator.LessOrEqual, TimeSpanInTheRange)] + [InlineData(TimeSpanLowerBound, TimeSpanUpperBound, ComparisonOperator.LessOrEqual, TimeSpanLowerBound)] + [InlineData(TimeSpanUpperBound, TimeSpanLowerBound, ComparisonOperator.GreaterThan, TimeSpanInTheRange)] + [InlineData(TimeSpanUpperBound, TimeSpanLowerBound, ComparisonOperator.GreaterThan, TimeSpanLowerBound)] + [InlineData(TimeSpanUpperBound, TimeSpanLowerBound, ComparisonOperator.GreaterOrEqual, TimeSpanInTheRange)] + [InlineData(TimeSpanUpperBound, TimeSpanLowerBound, ComparisonOperator.GreaterOrEqual, TimeSpanUpperBound)] + public async Task Can_filter_comparison_on_TimeSpan(string matchingValue, string nonMatchingValue, ComparisonOperator filterOperator, string filterValue) + { + // Arrange + var resource = new FilterableResource + { + SomeTimeSpan = TimeSpan.Parse(matchingValue, CultureInfo.InvariantCulture) + }; + + var otherResource = new FilterableResource + { + SomeTimeSpan = TimeSpan.Parse(nonMatchingValue, CultureInfo.InvariantCulture) + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.FilterableResources.AddRange(resource, otherResource); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/filterableResources?filter={filterOperator.ToString().Camelize()}(someTimeSpan,'{filterValue}')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("someTimeSpan").WhoseValue.Should().Be(resource.SomeTimeSpan); + } + + [Theory] + [InlineData(IsoDateOnlyLowerBound, IsoDateOnlyUpperBound, ComparisonOperator.LessThan, IsoDateOnlyInTheRange)] + [InlineData(IsoDateOnlyLowerBound, IsoDateOnlyUpperBound, ComparisonOperator.LessThan, IsoDateOnlyUpperBound)] + [InlineData(IsoDateOnlyLowerBound, IsoDateOnlyUpperBound, ComparisonOperator.LessOrEqual, IsoDateOnlyInTheRange)] + [InlineData(IsoDateOnlyLowerBound, IsoDateOnlyUpperBound, ComparisonOperator.LessOrEqual, IsoDateOnlyLowerBound)] + [InlineData(IsoDateOnlyUpperBound, IsoDateOnlyLowerBound, ComparisonOperator.GreaterThan, IsoDateOnlyInTheRange)] + [InlineData(IsoDateOnlyUpperBound, IsoDateOnlyLowerBound, ComparisonOperator.GreaterThan, IsoDateOnlyLowerBound)] + [InlineData(IsoDateOnlyUpperBound, IsoDateOnlyLowerBound, ComparisonOperator.GreaterOrEqual, IsoDateOnlyInTheRange)] + [InlineData(IsoDateOnlyUpperBound, IsoDateOnlyLowerBound, ComparisonOperator.GreaterOrEqual, IsoDateOnlyUpperBound)] + [InlineData(InvariantDateOnlyLowerBound, InvariantDateOnlyUpperBound, ComparisonOperator.LessThan, InvariantDateOnlyInTheRange)] + [InlineData(InvariantDateOnlyLowerBound, InvariantDateOnlyUpperBound, ComparisonOperator.LessThan, InvariantDateOnlyUpperBound)] + [InlineData(InvariantDateOnlyLowerBound, InvariantDateOnlyUpperBound, ComparisonOperator.LessOrEqual, InvariantDateOnlyInTheRange)] + [InlineData(InvariantDateOnlyLowerBound, InvariantDateOnlyUpperBound, ComparisonOperator.LessOrEqual, InvariantDateOnlyLowerBound)] + [InlineData(InvariantDateOnlyUpperBound, InvariantDateOnlyLowerBound, ComparisonOperator.GreaterThan, InvariantDateOnlyInTheRange)] + [InlineData(InvariantDateOnlyUpperBound, InvariantDateOnlyLowerBound, ComparisonOperator.GreaterThan, InvariantDateOnlyLowerBound)] + [InlineData(InvariantDateOnlyUpperBound, InvariantDateOnlyLowerBound, ComparisonOperator.GreaterOrEqual, InvariantDateOnlyInTheRange)] + [InlineData(InvariantDateOnlyUpperBound, InvariantDateOnlyLowerBound, ComparisonOperator.GreaterOrEqual, InvariantDateOnlyUpperBound)] + public async Task Can_filter_comparison_on_DateOnly(string matchingValue, string nonMatchingValue, ComparisonOperator filterOperator, string filterValue) + { + // Arrange + var resource = new FilterableResource + { + SomeDateOnly = DateOnly.Parse(matchingValue, CultureInfo.InvariantCulture) + }; + + var otherResource = new FilterableResource + { + SomeDateOnly = DateOnly.Parse(nonMatchingValue, CultureInfo.InvariantCulture) + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.FilterableResources.AddRange(resource, otherResource); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/filterableResources?filter={filterOperator.ToString().Camelize()}(someDateOnly,'{filterValue}')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("someDateOnly").WhoseValue.Should().Be(resource.SomeDateOnly); + } + + [Theory] + [InlineData(TimeOnlyLowerBound, TimeOnlyUpperBound, ComparisonOperator.LessThan, TimeOnlyInTheRange)] + [InlineData(TimeOnlyLowerBound, TimeOnlyUpperBound, ComparisonOperator.LessThan, TimeOnlyUpperBound)] + [InlineData(TimeOnlyLowerBound, TimeOnlyUpperBound, ComparisonOperator.LessOrEqual, TimeOnlyInTheRange)] + [InlineData(TimeOnlyLowerBound, TimeOnlyUpperBound, ComparisonOperator.LessOrEqual, TimeOnlyLowerBound)] + [InlineData(TimeOnlyUpperBound, TimeOnlyLowerBound, ComparisonOperator.GreaterThan, TimeOnlyInTheRange)] + [InlineData(TimeOnlyUpperBound, TimeOnlyLowerBound, ComparisonOperator.GreaterThan, TimeOnlyLowerBound)] + [InlineData(TimeOnlyUpperBound, TimeOnlyLowerBound, ComparisonOperator.GreaterOrEqual, TimeOnlyInTheRange)] + [InlineData(TimeOnlyUpperBound, TimeOnlyLowerBound, ComparisonOperator.GreaterOrEqual, TimeOnlyUpperBound)] + public async Task Can_filter_comparison_on_TimeOnly(string matchingValue, string nonMatchingValue, ComparisonOperator filterOperator, string filterValue) + { + // Arrange + var resource = new FilterableResource + { + SomeTimeOnly = TimeOnly.Parse(matchingValue, CultureInfo.InvariantCulture) + }; + + var otherResource = new FilterableResource + { + SomeTimeOnly = TimeOnly.Parse(nonMatchingValue, CultureInfo.InvariantCulture) + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.FilterableResources.AddRange(resource, otherResource); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/filterableResources?filter={filterOperator.ToString().Camelize()}(someTimeOnly,'{filterValue}')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someDateTimeInUtcZone") - .With(value => value.Should().Be(resource.SomeDateTimeInUtcZone)); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("someTimeOnly").WhoseValue.Should().Be(resource.SomeTimeOnly); } [Theory] @@ -461,11 +694,58 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someString").With(value => value.Should().Be(resource.SomeString)); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("someString").WhoseValue.Should().Be(resource.SomeString); + } + + [Fact] + public async Task Cannot_filter_text_match_on_non_string_value() + { + // Arrange + var parameterValue = new MarkedText("contains(^someInt32,'123')", '^'); + string route = $"/filterableResources?filter={parameterValue.Text}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"Attribute of type 'String' expected. {parameterValue}"); + error.Source.Should().NotBeNull(); + error.Source.Parameter.Should().Be("filter"); + } + + [Fact] + public async Task Cannot_filter_text_match_on_nested_non_string_value() + { + // Arrange + var parameterValue = new MarkedText("contains(parent.parent.^someInt32,'123')", '^'); + string route = $"/filterableResources?filter={parameterValue.Text}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"Attribute of type 'String' expected. {parameterValue}"); + error.Source.Should().NotBeNull(); + error.Source.Parameter.Should().Be("filter"); } [Theory] + [InlineData("yes", "no", "'yes'")] [InlineData("two", "one two", "'one','two','three'")] [InlineData("two", "nine", "'one','two','three','four','five'")] public async Task Can_filter_in_set(string matchingText, string nonMatchingText, string filterText) @@ -496,8 +776,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("someString").With(value => value.Should().Be(resource.SomeString)); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("someString").WhoseValue.Should().Be(resource.SomeString); } [Fact] @@ -527,7 +807,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(resource.StringId); } @@ -535,8 +815,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_filter_on_has_with_nested_condition() { // Arrange - var resources = new List - { + List resources = + [ new() { Children = new List @@ -557,7 +837,7 @@ public async Task Can_filter_on_has_with_nested_condition() } } } - }; + ]; await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -574,12 +854,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(resources[1].StringId); } [Fact] - public async Task Can_filter_on_count() + public async Task Can_filter_equality_on_count_at_left_side() { // Arrange var resource = new FilterableResource @@ -606,10 +886,70 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(resource.StringId); } + [Fact] + public async Task Can_filter_equality_on_count_at_both_sides() + { + // Arrange + var resource = new FilterableResource + { + Children = new List + { + new() + { + Children = new List + { + new() + } + } + } + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.FilterableResources.Add(resource); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/filterableResources?filter=equals(count(children),count(parent.children))"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(resource.Children.ElementAt(0).StringId); + } + + [Fact] + public async Task Cannot_filter_on_count_with_incompatible_value() + { + // Arrange + var parameterValue = new MarkedText("equals(count(children),^'ABC')", '^'); + string route = $"/filterableResources?filter={parameterValue.Text}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"Failed to convert 'ABC' of type 'String' to type 'Int32'. {parameterValue}"); + error.Source.Should().NotBeNull(); + error.Source.Parameter.Should().Be("filter"); + } + [Theory] [InlineData("and(equals(someString,'ABC'),equals(someInt32,'11'))")] [InlineData("and(equals(someString,'ABC'),equals(someInt32,'11'),equals(someEnum,'Tuesday'))")] @@ -647,7 +987,47 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(resource1.StringId); + } + + [Theory] + [InlineData("equals(and(equals(someString,'ABC'),equals(someInt32,'11')),'true')")] + [InlineData("equals(or(greaterThan(someInt32,'150'),equals(someEnum,'Tuesday')),'true')")] + [InlineData("equals(equals(someString,'ABC'),not(lessThan(someInt32,'10')))")] + public async Task Can_filter_nested_on_comparisons(string filterExpression) + { + // Arrange + var resource1 = new FilterableResource + { + SomeString = "ABC", + SomeInt32 = 11, + SomeEnum = DayOfWeek.Tuesday + }; + + var resource2 = new FilterableResource + { + SomeString = "XYZ", + SomeInt32 = 99, + SomeEnum = DayOfWeek.Saturday + }; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.FilterableResources.AddRange(resource1, resource2); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/filterableResources?filter={filterExpression}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(resource1.StringId); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs index 1af2251c6b..2f6c468f08 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterTests.cs @@ -18,6 +18,7 @@ public FilterTests(IntegrationTestContext, _testContext = testContext; testContext.UseController(); + testContext.UseController(); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); options.EnableLegacyFilterNotation = false; @@ -27,7 +28,8 @@ public FilterTests(IntegrationTestContext, public async Task Cannot_filter_in_unknown_scope() { // Arrange - const string route = $"/webAccounts?filter[{Unknown.Relationship}]=equals(title,null)"; + var parameterName = new MarkedText($"filter[^{Unknown.Relationship}]", '^'); + string route = $"/webAccounts?{parameterName.Text}=equals(title,null)"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -35,21 +37,22 @@ public async Task Cannot_filter_in_unknown_scope() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified filter is invalid."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'webAccounts'."); - error.Source.ShouldNotBeNull(); - error.Source.Parameter.Should().Be($"filter[{Unknown.Relationship}]"); + error.Detail.Should().Be($"Field '{Unknown.Relationship}' does not exist on resource type 'webAccounts'. {parameterName}"); + error.Source.Should().NotBeNull(); + error.Source.Parameter.Should().Be(parameterName.Text); } [Fact] public async Task Cannot_filter_in_unknown_nested_scope() { // Arrange - const string route = $"/webAccounts?filter[posts.{Unknown.Relationship}]=equals(title,null)"; + var parameterName = new MarkedText($"filter[posts.^{Unknown.Relationship}]", '^'); + string route = $"/webAccounts?{parameterName.Text}=equals(title,null)"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -57,21 +60,22 @@ public async Task Cannot_filter_in_unknown_nested_scope() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified filter is invalid."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' in 'posts.{Unknown.Relationship}' does not exist on resource type 'blogPosts'."); - error.Source.ShouldNotBeNull(); - error.Source.Parameter.Should().Be($"filter[posts.{Unknown.Relationship}]"); + error.Detail.Should().Be($"Field '{Unknown.Relationship}' does not exist on resource type 'blogPosts'. {parameterName}"); + error.Source.Should().NotBeNull(); + error.Source.Parameter.Should().Be(parameterName.Text); } [Fact] public async Task Cannot_filter_on_attribute_with_blocked_capability() { // Arrange - const string route = "/webAccounts?filter=equals(dateOfBirth,null)"; + var parameterValue = new MarkedText("equals(^dateOfBirth,null)", '^'); + string route = $"/webAccounts?filter={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -79,13 +83,36 @@ public async Task Cannot_filter_on_attribute_with_blocked_capability() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Filtering on the requested attribute is not allowed."); - error.Detail.Should().Be("Filtering on attribute 'dateOfBirth' is not allowed."); - error.Source.ShouldNotBeNull(); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"Filtering on attribute 'dateOfBirth' is not allowed. {parameterValue}"); + error.Source.Should().NotBeNull(); + error.Source.Parameter.Should().Be("filter"); + } + + [Fact] + public async Task Cannot_filter_on_ToMany_relationship_with_blocked_capability() + { + // Arrange + var parameterValue = new MarkedText("has(^appointments)", '^'); + string route = $"/calendars?filter={parameterValue.Text}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); + + responseDocument.Errors.Should().HaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.BadRequest); + error.Title.Should().Be("The specified filter is invalid."); + error.Detail.Should().Be($"Filtering on relationship 'appointments' is not allowed. {parameterValue}"); + error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("filter"); } @@ -93,7 +120,7 @@ public async Task Cannot_filter_on_attribute_with_blocked_capability() public async Task Can_filter_on_ID() { // Arrange - List accounts = _fakers.WebAccount.Generate(2); + List accounts = _fakers.WebAccount.GenerateList(2); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -110,8 +137,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(accounts[0].StringId); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("userName").With(value => value.Should().Be(accounts[0].UserName)); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("userName").WhoseValue.Should().Be(accounts[0].UserName); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterableResource.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterableResource.cs index dbc0323e9c..6a8ddc688f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterableResource.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Filtering/FilterableResource.cs @@ -77,12 +77,27 @@ public sealed class FilterableResource : Identifiable [Attr] public TimeSpan? SomeNullableTimeSpan { get; set; } + [Attr] + public DateOnly SomeDateOnly { get; set; } + + [Attr] + public DateOnly? SomeNullableDateOnly { get; set; } + + [Attr] + public TimeOnly SomeTimeOnly { get; set; } + + [Attr] + public TimeOnly? SomeNullableTimeOnly { get; set; } + [Attr] public DayOfWeek SomeEnum { get; set; } [Attr] public DayOfWeek? SomeNullableEnum { get; set; } + [HasOne] + public FilterableResource? Parent { get; set; } + [HasMany] public ICollection Children { get; set; } = new List(); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs index c835263fc6..3038ae9817 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Includes/IncludeTests.cs @@ -21,6 +21,7 @@ public IncludeTests(IntegrationTestContext testContext.UseController(); testContext.UseController(); testContext.UseController(); + testContext.UseController(); var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); options.MaximumIncludeDepth = null; @@ -30,8 +31,8 @@ public IncludeTests(IntegrationTestContext public async Task Can_include_in_primary_resources() { // Arrange - BlogPost post = _fakers.BlogPost.Generate(); - post.Author = _fakers.WebAccount.Generate(); + BlogPost post = _fakers.BlogPost.GenerateOne(); + post.Author = _fakers.WebAccount.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -48,22 +49,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(post.StringId); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("caption").With(value => value.Should().Be(post.Caption)); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("caption").WhoseValue.Should().Be(post.Caption); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Type.Should().Be("webAccounts"); responseDocument.Included[0].Id.Should().Be(post.Author.StringId); - responseDocument.Included[0].Attributes.ShouldContainKey("displayName").With(value => value.Should().Be(post.Author.DisplayName)); + responseDocument.Included[0].Attributes.Should().ContainKey("displayName").WhoseValue.Should().Be(post.Author.DisplayName); } [Fact] public async Task Can_include_in_primary_resource_by_ID() { // Arrange - BlogPost post = _fakers.BlogPost.Generate(); - post.Author = _fakers.WebAccount.Generate(); + BlogPost post = _fakers.BlogPost.GenerateOne(); + post.Author = _fakers.WebAccount.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -79,23 +80,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(post.StringId); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("caption").With(value => value.Should().Be(post.Caption)); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("caption").WhoseValue.Should().Be(post.Caption); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Type.Should().Be("webAccounts"); responseDocument.Included[0].Id.Should().Be(post.Author.StringId); - responseDocument.Included[0].Attributes.ShouldContainKey("displayName").With(value => value.Should().Be(post.Author.DisplayName)); + responseDocument.Included[0].Attributes.Should().ContainKey("displayName").WhoseValue.Should().Be(post.Author.DisplayName); } [Fact] public async Task Can_include_in_secondary_resource() { // Arrange - Blog blog = _fakers.Blog.Generate(); - blog.Owner = _fakers.WebAccount.Generate(); - blog.Owner.Posts = _fakers.BlogPost.Generate(1); + Blog blog = _fakers.Blog.GenerateOne(); + blog.Owner = _fakers.WebAccount.GenerateOne(); + blog.Owner.Posts = _fakers.BlogPost.GenerateList(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -111,23 +112,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(blog.Owner.StringId); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("displayName").With(value => value.Should().Be(blog.Owner.DisplayName)); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("displayName").WhoseValue.Should().Be(blog.Owner.DisplayName); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Type.Should().Be("blogPosts"); responseDocument.Included[0].Id.Should().Be(blog.Owner.Posts[0].StringId); - responseDocument.Included[0].Attributes.ShouldContainKey("caption").With(value => value.Should().Be(blog.Owner.Posts[0].Caption)); + responseDocument.Included[0].Attributes.Should().ContainKey("caption").WhoseValue.Should().Be(blog.Owner.Posts[0].Caption); } [Fact] public async Task Can_include_in_secondary_resources() { // Arrange - Blog blog = _fakers.Blog.Generate(); - blog.Posts = _fakers.BlogPost.Generate(1); - blog.Posts[0].Author = _fakers.WebAccount.Generate(); + Blog blog = _fakers.Blog.GenerateOne(); + blog.Posts = _fakers.BlogPost.GenerateList(1); + blog.Posts[0].Author = _fakers.WebAccount.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -143,23 +144,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(blog.Posts[0].StringId); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("caption").With(value => value.Should().Be(blog.Posts[0].Caption)); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("caption").WhoseValue.Should().Be(blog.Posts[0].Caption); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Type.Should().Be("webAccounts"); responseDocument.Included[0].Id.Should().Be(blog.Posts[0].Author!.StringId); - responseDocument.Included[0].Attributes.ShouldContainKey("displayName").With(value => value.Should().Be(blog.Posts[0].Author!.DisplayName)); + responseDocument.Included[0].Attributes.Should().ContainKey("displayName").WhoseValue.Should().Be(blog.Posts[0].Author!.DisplayName); } [Fact] public async Task Can_include_ToOne_relationships() { // Arrange - Comment comment = _fakers.Comment.Generate(); - comment.Author = _fakers.WebAccount.Generate(); - comment.Parent = _fakers.BlogPost.Generate(); + Comment comment = _fakers.Comment.GenerateOne(); + comment.Author = _fakers.WebAccount.GenerateOne(); + comment.Parent = _fakers.BlogPost.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -175,27 +176,27 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(comment.StringId); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("text").With(value => value.Should().Be(comment.Text)); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("text").WhoseValue.Should().Be(comment.Text); - responseDocument.Included.ShouldHaveCount(2); + responseDocument.Included.Should().HaveCount(2); responseDocument.Included[0].Type.Should().Be("webAccounts"); responseDocument.Included[0].Id.Should().Be(comment.Author.StringId); - responseDocument.Included[0].Attributes.ShouldContainKey("userName").With(value => value.Should().Be(comment.Author.UserName)); + responseDocument.Included[0].Attributes.Should().ContainKey("userName").WhoseValue.Should().Be(comment.Author.UserName); responseDocument.Included[1].Type.Should().Be("blogPosts"); responseDocument.Included[1].Id.Should().Be(comment.Parent.StringId); - responseDocument.Included[1].Attributes.ShouldContainKey("caption").With(value => value.Should().Be(comment.Parent.Caption)); + responseDocument.Included[1].Attributes.Should().ContainKey("caption").WhoseValue.Should().Be(comment.Parent.Caption); } [Fact] public async Task Can_include_OneToMany_relationship() { // Arrange - BlogPost post = _fakers.BlogPost.Generate(); - post.Comments = _fakers.Comment.Generate(1).ToHashSet(); + BlogPost post = _fakers.BlogPost.GenerateOne(); + post.Comments = _fakers.Comment.GenerateSet(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -211,24 +212,24 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(post.StringId); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("caption").With(value => value.Should().Be(post.Caption)); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("caption").WhoseValue.Should().Be(post.Caption); DateTime createdAt = post.Comments.Single().CreatedAt; - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Type.Should().Be("comments"); responseDocument.Included[0].Id.Should().Be(post.Comments.Single().StringId); - responseDocument.Included[0].Attributes.ShouldContainKey("createdAt").With(value => value.Should().Be(createdAt)); + responseDocument.Included[0].Attributes.Should().ContainKey("createdAt").WhoseValue.Should().Be(createdAt); } [Fact] public async Task Can_include_ManyToMany_relationship() { // Arrange - BlogPost post = _fakers.BlogPost.Generate(); - post.Labels = _fakers.Label.Generate(1).ToHashSet(); + BlogPost post = _fakers.BlogPost.GenerateOne(); + post.Labels = _fakers.Label.GenerateSet(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -244,22 +245,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(post.StringId); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("caption").With(value => value.Should().Be(post.Caption)); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("caption").WhoseValue.Should().Be(post.Caption); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Type.Should().Be("labels"); responseDocument.Included[0].Id.Should().Be(post.Labels.Single().StringId); - responseDocument.Included[0].Attributes.ShouldContainKey("name").With(value => value.Should().Be(post.Labels.Single().Name)); + responseDocument.Included[0].Attributes.Should().ContainKey("name").WhoseValue.Should().Be(post.Labels.Single().Name); } [Fact] - public async Task Can_include_ManyToMany_relationship_on_secondary_endpoint() + public async Task Can_include_ManyToMany_relationship_at_secondary_endpoint() { // Arrange - BlogPost post = _fakers.BlogPost.Generate(); - post.Labels = _fakers.Label.Generate(1).ToHashSet(); + BlogPost post = _fakers.BlogPost.GenerateOne(); + post.Labels = _fakers.Label.GenerateSet(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -275,25 +276,25 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Type.Should().Be("labels"); responseDocument.Data.ManyValue[0].Id.Should().Be(post.Labels.ElementAt(0).StringId); - responseDocument.Data.ManyValue[0].Attributes.ShouldContainKey("name").With(value => value.Should().Be(post.Labels.Single().Name)); + responseDocument.Data.ManyValue[0].Attributes.Should().ContainKey("name").WhoseValue.Should().Be(post.Labels.Single().Name); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Type.Should().Be("blogPosts"); responseDocument.Included[0].Id.Should().Be(post.StringId); - responseDocument.Included[0].Attributes.ShouldContainKey("caption").With(value => value.Should().Be(post.Caption)); + responseDocument.Included[0].Attributes.Should().ContainKey("caption").WhoseValue.Should().Be(post.Caption); } [Fact] public async Task Can_include_chain_of_ToOne_relationships() { // Arrange - Comment comment = _fakers.Comment.Generate(); - comment.Parent = _fakers.BlogPost.Generate(); - comment.Parent.Author = _fakers.WebAccount.Generate(); - comment.Parent.Author.Preferences = _fakers.AccountPreferences.Generate(); + Comment comment = _fakers.Comment.GenerateOne(); + comment.Parent = _fakers.BlogPost.GenerateOne(); + comment.Parent.Author = _fakers.WebAccount.GenerateOne(); + comment.Parent.Author.Preferences = _fakers.AccountPreferences.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -309,34 +310,34 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(comment.StringId); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("text").With(value => value.Should().Be(comment.Text)); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("text").WhoseValue.Should().Be(comment.Text); - responseDocument.Included.ShouldHaveCount(3); + responseDocument.Included.Should().HaveCount(3); responseDocument.Included[0].Type.Should().Be("blogPosts"); responseDocument.Included[0].Id.Should().Be(comment.Parent.StringId); - responseDocument.Included[0].Attributes.ShouldContainKey("caption").With(value => value.Should().Be(comment.Parent.Caption)); + responseDocument.Included[0].Attributes.Should().ContainKey("caption").WhoseValue.Should().Be(comment.Parent.Caption); responseDocument.Included[1].Type.Should().Be("webAccounts"); responseDocument.Included[1].Id.Should().Be(comment.Parent.Author.StringId); - responseDocument.Included[1].Attributes.ShouldContainKey("displayName").With(value => value.Should().Be(comment.Parent.Author.DisplayName)); + responseDocument.Included[1].Attributes.Should().ContainKey("displayName").WhoseValue.Should().Be(comment.Parent.Author.DisplayName); bool useDarkTheme = comment.Parent.Author.Preferences.UseDarkTheme; responseDocument.Included[2].Type.Should().Be("accountPreferences"); responseDocument.Included[2].Id.Should().Be(comment.Parent.Author.Preferences.StringId); - responseDocument.Included[2].Attributes.ShouldContainKey("useDarkTheme").With(value => value.Should().Be(useDarkTheme)); + responseDocument.Included[2].Attributes.Should().ContainKey("useDarkTheme").WhoseValue.Should().Be(useDarkTheme); } [Fact] public async Task Can_include_chain_of_OneToMany_relationships() { // Arrange - Blog blog = _fakers.Blog.Generate(); - blog.Posts = _fakers.BlogPost.Generate(1); - blog.Posts[0].Comments = _fakers.Comment.Generate(1).ToHashSet(); + Blog blog = _fakers.Blog.GenerateOne(); + blog.Posts = _fakers.BlogPost.GenerateList(1); + blog.Posts[0].Comments = _fakers.Comment.GenerateSet(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -352,32 +353,32 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(blog.StringId); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("title").With(value => value.Should().Be(blog.Title)); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("title").WhoseValue.Should().Be(blog.Title); - responseDocument.Included.ShouldHaveCount(2); + responseDocument.Included.Should().HaveCount(2); responseDocument.Included[0].Type.Should().Be("blogPosts"); responseDocument.Included[0].Id.Should().Be(blog.Posts[0].StringId); - responseDocument.Included[0].Attributes.ShouldContainKey("caption").With(value => value.Should().Be(blog.Posts[0].Caption)); + responseDocument.Included[0].Attributes.Should().ContainKey("caption").WhoseValue.Should().Be(blog.Posts[0].Caption); DateTime createdAt = blog.Posts[0].Comments.Single().CreatedAt; responseDocument.Included[1].Type.Should().Be("comments"); responseDocument.Included[1].Id.Should().Be(blog.Posts[0].Comments.Single().StringId); - responseDocument.Included[1].Attributes.ShouldContainKey("createdAt").With(value => value.Should().Be(createdAt)); + responseDocument.Included[1].Attributes.Should().ContainKey("createdAt").WhoseValue.Should().Be(createdAt); } [Fact] public async Task Can_include_chain_of_recursive_relationships() { // Arrange - Comment comment = _fakers.Comment.Generate(); - comment.Parent = _fakers.BlogPost.Generate(); - comment.Parent.Author = _fakers.WebAccount.Generate(); - comment.Parent.Comments = _fakers.Comment.Generate(2).ToHashSet(); - comment.Parent.Comments.ElementAt(0).Author = _fakers.WebAccount.Generate(); + Comment comment = _fakers.Comment.GenerateOne(); + comment.Parent = _fakers.BlogPost.GenerateOne(); + comment.Parent.Author = _fakers.WebAccount.GenerateOne(); + comment.Parent.Comments = _fakers.Comment.GenerateSet(2); + comment.Parent.Comments.ElementAt(0).Author = _fakers.WebAccount.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -393,46 +394,42 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(comment.StringId); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("text").With(value => value.Should().Be(comment.Text)); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("text").WhoseValue.Should().Be(comment.Text); - responseDocument.Included.ShouldHaveCount(5); + responseDocument.Included.Should().HaveCount(4); responseDocument.Included[0].Type.Should().Be("blogPosts"); responseDocument.Included[0].Id.Should().Be(comment.Parent.StringId); - responseDocument.Included[0].Attributes.ShouldContainKey("caption").With(value => value.Should().Be(comment.Parent.Caption)); + responseDocument.Included[0].Attributes.Should().ContainKey("caption").WhoseValue.Should().Be(comment.Parent.Caption); responseDocument.Included[1].Type.Should().Be("comments"); - responseDocument.Included[1].Id.Should().Be(comment.StringId); - responseDocument.Included[1].Attributes.ShouldContainKey("text").With(value => value.Should().Be(comment.Text)); - - responseDocument.Included[2].Type.Should().Be("comments"); - responseDocument.Included[2].Id.Should().Be(comment.Parent.Comments.ElementAt(0).StringId); - responseDocument.Included[2].Attributes.ShouldContainKey("text").With(value => value.Should().Be(comment.Parent.Comments.ElementAt(0).Text)); + responseDocument.Included[1].Id.Should().Be(comment.Parent.Comments.ElementAt(0).StringId); + responseDocument.Included[1].Attributes.Should().ContainKey("text").WhoseValue.Should().Be(comment.Parent.Comments.ElementAt(0).Text); string userName = comment.Parent.Comments.ElementAt(0).Author!.UserName; - responseDocument.Included[3].Type.Should().Be("webAccounts"); - responseDocument.Included[3].Id.Should().Be(comment.Parent.Comments.ElementAt(0).Author!.StringId); - responseDocument.Included[3].Attributes.ShouldContainKey("userName").With(value => value.Should().Be(userName)); + responseDocument.Included[2].Type.Should().Be("webAccounts"); + responseDocument.Included[2].Id.Should().Be(comment.Parent.Comments.ElementAt(0).Author!.StringId); + responseDocument.Included[2].Attributes.Should().ContainKey("userName").WhoseValue.Should().Be(userName); - responseDocument.Included[4].Type.Should().Be("comments"); - responseDocument.Included[4].Id.Should().Be(comment.Parent.Comments.ElementAt(1).StringId); - responseDocument.Included[4].Attributes.ShouldContainKey("text").With(value => value.Should().Be(comment.Parent.Comments.ElementAt(1).Text)); + responseDocument.Included[3].Type.Should().Be("comments"); + responseDocument.Included[3].Id.Should().Be(comment.Parent.Comments.ElementAt(1).StringId); + responseDocument.Included[3].Attributes.Should().ContainKey("text").WhoseValue.Should().Be(comment.Parent.Comments.ElementAt(1).Text); } [Fact] public async Task Can_include_chain_of_relationships_with_multiple_paths() { // Arrange - Blog blog = _fakers.Blog.Generate(); - blog.Posts = _fakers.BlogPost.Generate(1); - blog.Posts[0].Author = _fakers.WebAccount.Generate(); - blog.Posts[0].Author!.Preferences = _fakers.AccountPreferences.Generate(); - blog.Posts[0].Comments = _fakers.Comment.Generate(2).ToHashSet(); - blog.Posts[0].Comments.ElementAt(0).Author = _fakers.WebAccount.Generate(); - blog.Posts[0].Comments.ElementAt(0).Author!.Posts = _fakers.BlogPost.Generate(1); + Blog blog = _fakers.Blog.GenerateOne(); + blog.Posts = _fakers.BlogPost.GenerateList(1); + blog.Posts[0].Author = _fakers.WebAccount.GenerateOne(); + blog.Posts[0].Author!.Preferences = _fakers.AccountPreferences.GenerateOne(); + blog.Posts[0].Comments = _fakers.Comment.GenerateSet(2); + blog.Posts[0].Comments.ElementAt(0).Author = _fakers.WebAccount.GenerateOne(); + blog.Posts[0].Comments.ElementAt(0).Author!.Posts = _fakers.BlogPost.GenerateList(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -448,34 +445,34 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(blog.StringId); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("posts").With(value => + responseDocument.Data.SingleValue.Relationships.Should().ContainKey("posts").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Data.ManyValue.ShouldNotBeEmpty(); + value.Should().NotBeNull(); + value.Data.ManyValue.Should().NotBeEmpty(); value.Data.ManyValue[0].Type.Should().Be("blogPosts"); value.Data.ManyValue[0].Id.Should().Be(blog.Posts[0].StringId); }); - responseDocument.Included.ShouldHaveCount(7); + responseDocument.Included.Should().HaveCount(7); responseDocument.Included[0].Type.Should().Be("blogPosts"); responseDocument.Included[0].Id.Should().Be(blog.Posts[0].StringId); - responseDocument.Included[0].Relationships.ShouldContainKey("author").With(value => + responseDocument.Included[0].Relationships.Should().ContainKey("author").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Data.SingleValue.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Data.SingleValue.Should().NotBeNull(); value.Data.SingleValue.Type.Should().Be("webAccounts"); value.Data.SingleValue.Id.Should().Be(blog.Posts[0].Author!.StringId); }); - responseDocument.Included[0].Relationships.ShouldContainKey("comments").With(value => + responseDocument.Included[0].Relationships.Should().ContainKey("comments").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Data.ManyValue.ShouldNotBeEmpty(); + value.Should().NotBeNull(); + value.Data.ManyValue.Should().NotBeEmpty(); value.Data.ManyValue[0].Type.Should().Be("comments"); value.Data.ManyValue[0].Id.Should().Be(blog.Posts[0].Comments.ElementAt(0).StringId); }); @@ -483,17 +480,17 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[1].Type.Should().Be("webAccounts"); responseDocument.Included[1].Id.Should().Be(blog.Posts[0].Author!.StringId); - responseDocument.Included[1].Relationships.ShouldContainKey("preferences").With(value => + responseDocument.Included[1].Relationships.Should().ContainKey("preferences").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Data.SingleValue.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Data.SingleValue.Should().NotBeNull(); value.Data.SingleValue.Type.Should().Be("accountPreferences"); value.Data.SingleValue.Id.Should().Be(blog.Posts[0].Author!.Preferences!.StringId); }); - responseDocument.Included[1].Relationships.ShouldContainKey("posts").With(value => + responseDocument.Included[1].Relationships.Should().ContainKey("posts").WhoseValue.With(value => { - value.ShouldNotBeNull(); + value.Should().NotBeNull(); value.Data.Value.Should().BeNull(); }); @@ -503,10 +500,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[3].Type.Should().Be("comments"); responseDocument.Included[3].Id.Should().Be(blog.Posts[0].Comments.ElementAt(0).StringId); - responseDocument.Included[3].Relationships.ShouldContainKey("author").With(value => + responseDocument.Included[3].Relationships.Should().ContainKey("author").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Data.SingleValue.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Data.SingleValue.Should().NotBeNull(); value.Data.SingleValue.Type.Should().Be("webAccounts"); value.Data.SingleValue.Id.Should().Be(blog.Posts[0].Comments.ElementAt(0).Author!.StringId); }); @@ -514,41 +511,41 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[4].Type.Should().Be("webAccounts"); responseDocument.Included[4].Id.Should().Be(blog.Posts[0].Comments.ElementAt(0).Author!.StringId); - responseDocument.Included[4].Relationships.ShouldContainKey("posts").With(value => + responseDocument.Included[4].Relationships.Should().ContainKey("posts").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Data.ManyValue.ShouldNotBeEmpty(); + value.Should().NotBeNull(); + value.Data.ManyValue.Should().NotBeEmpty(); value.Data.ManyValue[0].Type.Should().Be("blogPosts"); value.Data.ManyValue[0].Id.Should().Be(blog.Posts[0].Comments.ElementAt(0).Author!.Posts[0].StringId); }); - responseDocument.Included[4].Relationships.ShouldContainKey("preferences").With(value => + responseDocument.Included[4].Relationships.Should().ContainKey("preferences").WhoseValue.With(value => { - value.ShouldNotBeNull(); + value.Should().NotBeNull(); value.Data.Value.Should().BeNull(); }); responseDocument.Included[5].Type.Should().Be("blogPosts"); responseDocument.Included[5].Id.Should().Be(blog.Posts[0].Comments.ElementAt(0).Author!.Posts[0].StringId); - responseDocument.Included[5].Relationships.ShouldContainKey("author").With(value => + responseDocument.Included[5].Relationships.Should().ContainKey("author").WhoseValue.With(value => { - value.ShouldNotBeNull(); + value.Should().NotBeNull(); value.Data.Value.Should().BeNull(); }); - responseDocument.Included[5].Relationships.ShouldContainKey("comments").With(value => + responseDocument.Included[5].Relationships.Should().ContainKey("comments").WhoseValue.With(value => { - value.ShouldNotBeNull(); + value.Should().NotBeNull(); value.Data.Value.Should().BeNull(); }); responseDocument.Included[6].Type.Should().Be("comments"); responseDocument.Included[6].Id.Should().Be(blog.Posts[0].Comments.ElementAt(1).StringId); - responseDocument.Included[5].Relationships.ShouldContainKey("author").With(value => + responseDocument.Included[5].Relationships.Should().ContainKey("author").WhoseValue.With(value => { - value.ShouldNotBeNull(); + value.Should().NotBeNull(); value.Data.Value.Should().BeNull(); }); } @@ -556,23 +553,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => [Fact] public async Task Can_include_chain_of_relationships_with_reused_resources() { - WebAccount author = _fakers.WebAccount.Generate(); - author.Preferences = _fakers.AccountPreferences.Generate(); - author.LoginAttempts = _fakers.LoginAttempt.Generate(1); + WebAccount author = _fakers.WebAccount.GenerateOne(); + author.Preferences = _fakers.AccountPreferences.GenerateOne(); + author.LoginAttempts = _fakers.LoginAttempt.GenerateList(1); - WebAccount reviewer = _fakers.WebAccount.Generate(); - reviewer.Preferences = _fakers.AccountPreferences.Generate(); - reviewer.LoginAttempts = _fakers.LoginAttempt.Generate(1); + WebAccount reviewer = _fakers.WebAccount.GenerateOne(); + reviewer.Preferences = _fakers.AccountPreferences.GenerateOne(); + reviewer.LoginAttempts = _fakers.LoginAttempt.GenerateList(1); - BlogPost post1 = _fakers.BlogPost.Generate(); + BlogPost post1 = _fakers.BlogPost.GenerateOne(); post1.Author = author; post1.Reviewer = reviewer; - WebAccount person = _fakers.WebAccount.Generate(); - person.Preferences = _fakers.AccountPreferences.Generate(); - person.LoginAttempts = _fakers.LoginAttempt.Generate(1); + WebAccount person = _fakers.WebAccount.GenerateOne(); + person.Preferences = _fakers.AccountPreferences.GenerateOne(); + person.LoginAttempts = _fakers.LoginAttempt.GenerateList(1); - BlogPost post2 = _fakers.BlogPost.Generate(); + BlogPost post2 = _fakers.BlogPost.GenerateOne(); post2.Author = person; post2.Reviewer = person; @@ -591,23 +588,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue.Should().HaveCount(2); responseDocument.Data.ManyValue[0].Type.Should().Be("blogPosts"); responseDocument.Data.ManyValue[0].Id.Should().Be(post1.StringId); - responseDocument.Data.ManyValue[0].Relationships.ShouldContainKey("author").With(value => + responseDocument.Data.ManyValue[0].Relationships.Should().ContainKey("author").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Data.SingleValue.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Data.SingleValue.Should().NotBeNull(); value.Data.SingleValue.Type.Should().Be("webAccounts"); value.Data.SingleValue.Id.Should().Be(author.StringId); }); - responseDocument.Data.ManyValue[0].Relationships.ShouldContainKey("reviewer").With(value => + responseDocument.Data.ManyValue[0].Relationships.Should().ContainKey("reviewer").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Data.SingleValue.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Data.SingleValue.Should().NotBeNull(); value.Data.SingleValue.Type.Should().Be("webAccounts"); value.Data.SingleValue.Id.Should().Be(reviewer.StringId); }); @@ -615,38 +612,38 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.ManyValue[1].Type.Should().Be("blogPosts"); responseDocument.Data.ManyValue[1].Id.Should().Be(post2.StringId); - responseDocument.Data.ManyValue[1].Relationships.ShouldContainKey("author").With(value => + responseDocument.Data.ManyValue[1].Relationships.Should().ContainKey("author").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Data.SingleValue.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Data.SingleValue.Should().NotBeNull(); value.Data.SingleValue.Type.Should().Be("webAccounts"); value.Data.SingleValue.Id.Should().Be(person.StringId); }); - responseDocument.Data.ManyValue[1].Relationships.ShouldContainKey("reviewer").With(value => + responseDocument.Data.ManyValue[1].Relationships.Should().ContainKey("reviewer").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Data.SingleValue.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Data.SingleValue.Should().NotBeNull(); value.Data.SingleValue.Type.Should().Be("webAccounts"); value.Data.SingleValue.Id.Should().Be(person.StringId); }); - responseDocument.Included.ShouldHaveCount(7); + responseDocument.Included.Should().HaveCount(7); responseDocument.Included[0].Type.Should().Be("webAccounts"); responseDocument.Included[0].Id.Should().Be(author.StringId); - responseDocument.Included[0].Relationships.ShouldContainKey("preferences").With(value => + responseDocument.Included[0].Relationships.Should().ContainKey("preferences").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Data.SingleValue.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Data.SingleValue.Should().NotBeNull(); value.Data.SingleValue.Type.Should().Be("accountPreferences"); value.Data.SingleValue.Id.Should().Be(author.Preferences.StringId); }); - responseDocument.Included[0].Relationships.ShouldContainKey("loginAttempts").With(value => + responseDocument.Included[0].Relationships.Should().ContainKey("loginAttempts").WhoseValue.With(value => { - value.ShouldNotBeNull(); + value.Should().NotBeNull(); value.Data.Value.Should().BeNull(); }); @@ -656,16 +653,16 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[2].Type.Should().Be("webAccounts"); responseDocument.Included[2].Id.Should().Be(reviewer.StringId); - responseDocument.Included[2].Relationships.ShouldContainKey("preferences").With(value => + responseDocument.Included[2].Relationships.Should().ContainKey("preferences").WhoseValue.With(value => { - value.ShouldNotBeNull(); + value.Should().NotBeNull(); value.Data.Value.Should().BeNull(); }); - responseDocument.Included[2].Relationships.ShouldContainKey("loginAttempts").With(value => + responseDocument.Included[2].Relationships.Should().ContainKey("loginAttempts").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Data.ManyValue.ShouldNotBeEmpty(); + value.Should().NotBeNull(); + value.Data.ManyValue.Should().NotBeEmpty(); value.Data.ManyValue[0].Type.Should().Be("loginAttempts"); value.Data.ManyValue[0].Id.Should().Be(reviewer.LoginAttempts[0].StringId); }); @@ -676,18 +673,18 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[4].Type.Should().Be("webAccounts"); responseDocument.Included[4].Id.Should().Be(person.StringId); - responseDocument.Included[4].Relationships.ShouldContainKey("preferences").With(value => + responseDocument.Included[4].Relationships.Should().ContainKey("preferences").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Data.SingleValue.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Data.SingleValue.Should().NotBeNull(); value.Data.SingleValue.Type.Should().Be("accountPreferences"); value.Data.SingleValue.Id.Should().Be(person.Preferences.StringId); }); - responseDocument.Included[4].Relationships.ShouldContainKey("loginAttempts").With(value => + responseDocument.Included[4].Relationships.Should().ContainKey("loginAttempts").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Data.ManyValue.ShouldNotBeEmpty(); + value.Should().NotBeNull(); + value.Data.ManyValue.Should().NotBeEmpty(); value.Data.ManyValue[0].Type.Should().Be("loginAttempts"); value.Data.ManyValue[0].Id.Should().Be(person.LoginAttempts[0].StringId); }); @@ -702,11 +699,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => [Fact] public async Task Can_include_chain_with_cyclic_dependency() { - List posts = _fakers.BlogPost.Generate(1); + List posts = _fakers.BlogPost.GenerateList(1); - Blog blog = _fakers.Blog.Generate(); + Blog blog = _fakers.Blog.GenerateOne(); blog.Posts = posts; - blog.Posts[0].Author = _fakers.WebAccount.Generate(); + blog.Posts[0].Author = _fakers.WebAccount.GenerateOne(); blog.Posts[0].Author!.Posts = posts; await _testContext.RunOnDatabaseAsync(async dbContext => @@ -723,27 +720,27 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Type.Should().Be("blogs"); responseDocument.Data.SingleValue.Id.Should().Be(blog.StringId); - responseDocument.Data.SingleValue.Relationships.ShouldContainKey("posts").With(value => + responseDocument.Data.SingleValue.Relationships.Should().ContainKey("posts").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Data.ManyValue.ShouldNotBeEmpty(); + value.Should().NotBeNull(); + value.Data.ManyValue.Should().NotBeEmpty(); value.Data.ManyValue[0].Type.Should().Be("blogPosts"); value.Data.ManyValue[0].Id.Should().Be(blog.Posts[0].StringId); }); - responseDocument.Included.ShouldHaveCount(2); + responseDocument.Included.Should().HaveCount(2); responseDocument.Included[0].Type.Should().Be("blogPosts"); responseDocument.Included[0].Id.Should().Be(blog.Posts[0].StringId); - responseDocument.Included[0].Relationships.ShouldContainKey("author").With(value => + responseDocument.Included[0].Relationships.Should().ContainKey("author").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Data.SingleValue.ShouldNotBeNull(); + value.Should().NotBeNull(); + value.Data.SingleValue.Should().NotBeNull(); value.Data.SingleValue.Type.Should().Be("webAccounts"); value.Data.SingleValue.Id.Should().Be(blog.Posts[0].Author!.StringId); }); @@ -751,10 +748,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[1].Type.Should().Be("webAccounts"); responseDocument.Included[1].Id.Should().Be(blog.Posts[0].Author!.StringId); - responseDocument.Included[1].Relationships.ShouldContainKey("posts").With(value => + responseDocument.Included[1].Relationships.Should().ContainKey("posts").WhoseValue.With(value => { - value.ShouldNotBeNull(); - value.Data.ManyValue.ShouldNotBeEmpty(); + value.Should().NotBeNull(); + value.Data.ManyValue.Should().NotBeEmpty(); value.Data.ManyValue[0].Type.Should().Be("blogPosts"); value.Data.ManyValue[0].Id.Should().Be(blog.Posts[0].StringId); }); @@ -764,9 +761,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Prevents_duplicate_includes_over_single_resource() { // Arrange - WebAccount account = _fakers.WebAccount.Generate(); + WebAccount account = _fakers.WebAccount.GenerateOne(); - BlogPost post = _fakers.BlogPost.Generate(); + BlogPost post = _fakers.BlogPost.GenerateOne(); post.Author = account; post.Reviewer = account; @@ -784,23 +781,23 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Should().NotBeNull(); responseDocument.Data.SingleValue.Id.Should().Be(post.StringId); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("caption").With(value => value.Should().Be(post.Caption)); + responseDocument.Data.SingleValue.Attributes.Should().ContainKey("caption").WhoseValue.Should().Be(post.Caption); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Type.Should().Be("webAccounts"); responseDocument.Included[0].Id.Should().Be(account.StringId); - responseDocument.Included[0].Attributes.ShouldContainKey("userName").With(value => value.Should().Be(account.UserName)); + responseDocument.Included[0].Attributes.Should().ContainKey("userName").WhoseValue.Should().Be(account.UserName); } [Fact] public async Task Prevents_duplicate_includes_over_multiple_resources() { // Arrange - WebAccount account = _fakers.WebAccount.Generate(); + WebAccount account = _fakers.WebAccount.GenerateOne(); - List posts = _fakers.BlogPost.Generate(2); + List posts = _fakers.BlogPost.GenerateList(2); posts[0].Author = account; posts[1].Author = account; @@ -819,19 +816,45 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue.Should().HaveCount(2); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Type.Should().Be("webAccounts"); responseDocument.Included[0].Id.Should().Be(account.StringId); - responseDocument.Included[0].Attributes.ShouldContainKey("userName").With(value => value.Should().Be(account.UserName)); + responseDocument.Included[0].Attributes.Should().ContainKey("userName").WhoseValue.Should().Be(account.UserName); + } + + [Fact] + public async Task Can_select_empty_includes() + { + // Arrange + WebAccount account = _fakers.WebAccount.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Accounts.Add(account); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/webAccounts/{account.StringId}?include="; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.Should().NotBeNull(); + + responseDocument.Included.Should().BeEmpty(); } [Fact] public async Task Cannot_include_unknown_relationship() { // Arrange - const string route = $"/webAccounts?include={Unknown.Relationship}"; + var parameterValue = new MarkedText($"^{Unknown.Relationship}", '^'); + string route = $"/webAccounts?include={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -839,13 +862,13 @@ public async Task Cannot_include_unknown_relationship() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified include is invalid."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'webAccounts'."); - error.Source.ShouldNotBeNull(); + error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'webAccounts'. {parameterValue}"); + error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("include"); } @@ -853,7 +876,8 @@ public async Task Cannot_include_unknown_relationship() public async Task Cannot_include_unknown_nested_relationship() { // Arrange - const string route = $"/blogs?include=posts.{Unknown.Relationship}"; + var parameterValue = new MarkedText($"posts.^{Unknown.Relationship}", '^'); + string route = $"/blogs?include={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -861,21 +885,22 @@ public async Task Cannot_include_unknown_nested_relationship() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified include is invalid."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' in 'posts.{Unknown.Relationship}' does not exist on resource type 'blogPosts'."); - error.Source.ShouldNotBeNull(); + error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'blogPosts'. {parameterValue}"); + error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("include"); } [Fact] - public async Task Cannot_include_relationship_with_blocked_capability() + public async Task Cannot_include_relationship_when_inclusion_blocked() { // Arrange - const string route = "/blogPosts?include=parent"; + var parameterValue = new MarkedText("^parent", '^'); + string route = $"/blogPosts?include={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -883,21 +908,22 @@ public async Task Cannot_include_relationship_with_blocked_capability() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Including the requested relationship is not allowed."); - error.Detail.Should().Be("Including the relationship 'parent' on 'blogPosts' is not allowed."); - error.Source.ShouldNotBeNull(); + error.Title.Should().Be("The specified include is invalid."); + error.Detail.Should().Be($"Including the relationship 'parent' on 'blogPosts' is not allowed. {parameterValue}"); + error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("include"); } [Fact] - public async Task Cannot_include_relationship_with_nested_blocked_capability() + public async Task Cannot_include_relationship_when_nested_inclusion_blocked() { // Arrange - const string route = "/blogs?include=posts.parent"; + var parameterValue = new MarkedText("posts.^parent", '^'); + string route = $"/blogs?include={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -905,22 +931,101 @@ public async Task Cannot_include_relationship_with_nested_blocked_capability() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Including the requested relationship is not allowed."); - error.Detail.Should().Be("Including the relationship 'parent' in 'posts.parent' on 'blogPosts' is not allowed."); - error.Source.ShouldNotBeNull(); + error.Title.Should().Be("The specified include is invalid."); + error.Detail.Should().Be($"Including the relationship 'parent' on 'blogPosts' is not allowed. {parameterValue}"); + error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("include"); } + [Fact] + public async Task Hides_relationship_and_related_resources_when_viewing_blocked() + { + // Arrange + Calendar calendar = _fakers.Calendar.GenerateOne(); + calendar.Appointments = _fakers.Appointment.GenerateSet(2); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Calendars.Add(calendar); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/calendars/{calendar.StringId}?include=appointments"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("calendars"); + responseDocument.Data.SingleValue.Id.Should().Be(calendar.StringId); + + responseDocument.Data.SingleValue.Relationships.Should().NotBeEmpty(); + responseDocument.Data.SingleValue.Relationships.Should().NotContainKey("appointments"); + + responseDocument.Included.Should().BeEmpty(); + } + + [Fact] + public async Task Hides_relationship_but_includes_related_resource_when_viewing_blocked_but_accessible_via_other_path() + { + // Arrange + Calendar calendar = _fakers.Calendar.GenerateOne(); + calendar.MostRecentAppointment = _fakers.Appointment.GenerateOne(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Calendars.Add(calendar); + await dbContext.SaveChangesAsync(); + + calendar.Appointments = new[] + { + _fakers.Appointment.GenerateOne(), + calendar.MostRecentAppointment + }.ToHashSet(); + + await dbContext.SaveChangesAsync(); + }); + + string route = $"/calendars/{calendar.StringId}?include=appointments,mostRecentAppointment"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("calendars"); + responseDocument.Data.SingleValue.Id.Should().Be(calendar.StringId); + + responseDocument.Data.SingleValue.Relationships.Should().ContainKey("mostRecentAppointment").WhoseValue.With(value => + { + value.Should().NotBeNull(); + value.Data.SingleValue.Should().NotBeNull(); + value.Data.SingleValue.Type.Should().Be("appointments"); + value.Data.SingleValue.Id.Should().Be(calendar.MostRecentAppointment.StringId); + }); + + responseDocument.Data.SingleValue.Relationships.Should().NotContainKey("appointments"); + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Type.Should().Be("appointments"); + responseDocument.Included[0].Id.Should().Be(calendar.MostRecentAppointment.StringId); + } + [Fact] public async Task Ignores_null_parent_in_nested_include() { // Arrange - List posts = _fakers.BlogPost.Generate(2); - posts[0].Reviewer = _fakers.WebAccount.Generate(); + List posts = _fakers.BlogPost.GenerateList(2); + posts[0].Reviewer = _fakers.WebAccount.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -937,26 +1042,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue.Should().HaveCount(2); - responseDocument.Data.ManyValue.Should().OnlyContain(resource => resource.Relationships.ShouldContainKey("reviewer") != null); + responseDocument.Data.ManyValue.Should().OnlyContain(resource => resource.Relationships.Should().ContainKey2("reviewer").WhoseValue != null); ResourceObject[] postWithReviewer = responseDocument.Data.ManyValue .Where(resource => resource.Relationships!.First(pair => pair.Key == "reviewer").Value!.Data.SingleValue != null).ToArray(); - postWithReviewer.ShouldHaveCount(1); - postWithReviewer[0].Attributes.ShouldContainKey("caption").With(value => value.Should().Be(posts[0].Caption)); + postWithReviewer.Should().HaveCount(1); + postWithReviewer[0].Attributes.Should().ContainKey("caption").WhoseValue.Should().Be(posts[0].Caption); ResourceObject[] postWithoutReviewer = responseDocument.Data.ManyValue .Where(resource => resource.Relationships!.First(pair => pair.Key == "reviewer").Value!.Data.SingleValue == null).ToArray(); - postWithoutReviewer.ShouldHaveCount(1); - postWithoutReviewer[0].Attributes.ShouldContainKey("caption").With(value => value.Should().Be(posts[1].Caption)); + postWithoutReviewer.Should().HaveCount(1); + postWithoutReviewer[0].Attributes.Should().ContainKey("caption").WhoseValue.Should().Be(posts[1].Caption); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Type.Should().Be("webAccounts"); responseDocument.Included[0].Id.Should().Be(posts[0].Reviewer!.StringId); - responseDocument.Included[0].Attributes.ShouldContainKey("userName").With(value => value.Should().Be(posts[0].Reviewer!.UserName)); + responseDocument.Included[0].Attributes.Should().ContainKey("userName").WhoseValue.Should().Be(posts[0].Reviewer!.UserName); } [Fact] @@ -966,7 +1071,7 @@ public async Task Can_include_at_configured_maximum_inclusion_depth() var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); options.MaximumIncludeDepth = 1; - Blog blog = _fakers.Blog.Generate(); + Blog blog = _fakers.Blog.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -990,7 +1095,8 @@ public async Task Cannot_exceed_configured_maximum_inclusion_depth() var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); options.MaximumIncludeDepth = 1; - const string route = "/blogs/123/owner?include=posts.comments"; + var parameterValue = new MarkedText("^posts.comments", '^'); + string route = $"/blogs/123/owner?include={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -998,13 +1104,13 @@ public async Task Cannot_exceed_configured_maximum_inclusion_depth() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); error.Title.Should().Be("The specified include is invalid."); - error.Detail.Should().Be("Including 'posts.comments' exceeds the maximum inclusion depth of 1."); - error.Source.ShouldNotBeNull(); + error.Detail.Should().Be($"Including 'posts.comments' exceeds the maximum inclusion depth of 1. {parameterValue}"); + error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("include"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Label.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Label.cs index acd1fa446c..07804aeac6 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Label.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Label.cs @@ -5,6 +5,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings; [UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.QueryStrings")] public sealed class Label : Identifiable { [Attr] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/LoginAttempt.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/LoginAttempt.cs index 73557d7fcf..007182781a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/LoginAttempt.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/LoginAttempt.cs @@ -5,6 +5,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings; [UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.QueryStrings")] public sealed class LoginAttempt : Identifiable { [Attr] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs index 194e73a5dc..3fb7714c3f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithTotalCountTests.cs @@ -12,6 +12,7 @@ public sealed class PaginationWithTotalCountTests : IClassFixture, QueryStringDbContext> _testContext; private readonly QueryStringFakers _fakers = new(); @@ -36,7 +37,7 @@ public PaginationWithTotalCountTests(IntegrationTestContext posts = _fakers.BlogPost.Generate(2); + List posts = _fakers.BlogPost.GenerateList(2); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -53,10 +54,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(posts[1].StringId); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.First.Should().Be($"{HostPrefix}/blogPosts?page%5Bsize%5D=1"); responseDocument.Links.Last.Should().Be($"{HostPrefix}/blogPosts?page%5Bnumber%5D=2&page%5Bsize%5D=1"); @@ -65,10 +66,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_paginate_in_single_primary_endpoint() + public async Task Cannot_paginate_in_primary_resource() { // Arrange - BlogPost post = _fakers.BlogPost.Generate(); + BlogPost post = _fakers.BlogPost.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -84,13 +85,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); - error.Detail.Should().Be("This query string parameter can only be used on a collection of resources (not on a single resource)."); - error.Source.ShouldNotBeNull(); + error.Title.Should().Be("The specified pagination is invalid."); + error.Detail.Should().Be($"{CollectionErrorMessage} Failed at position 1: ^page[number]"); + error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("page[number]"); } @@ -98,11 +99,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_paginate_in_secondary_resources() { // Arrange - Blog blog = _fakers.Blog.Generate(); - blog.Posts = _fakers.BlogPost.Generate(5); + Blog blog = _fakers.Blog.GenerateOne(); + blog.Posts = _fakers.BlogPost.GenerateList(5); - Blog otherBlog = _fakers.Blog.Generate(); - otherBlog.Posts = _fakers.BlogPost.Generate(1); + Blog otherBlog = _fakers.Blog.GenerateOne(); + otherBlog.Posts = _fakers.BlogPost.GenerateList(1); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -118,10 +119,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(blog.Posts[2].StringId); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.First.Should().Be($"{HostPrefix}/blogs/{blog.StringId}/posts?page%5Bsize%5D=1"); responseDocument.Links.Last.Should().Be($"{HostPrefix}/blogs/{blog.StringId}/posts?page%5Bnumber%5D=5&page%5Bsize%5D=1"); @@ -133,8 +134,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_paginate_in_secondary_resources_without_inverse_relationship() { // Arrange - WebAccount? account = _fakers.WebAccount.Generate(); - account.LoginAttempts = _fakers.LoginAttempt.Generate(2); + WebAccount account = _fakers.WebAccount.GenerateOne(); + account.LoginAttempts = _fakers.LoginAttempt.GenerateList(2); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -150,10 +151,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(account.LoginAttempts[1].StringId); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.First.Should().Be($"{HostPrefix}/webAccounts/{account.StringId}/loginAttempts?page%5Bsize%5D=1"); responseDocument.Links.Last.Should().BeNull(); @@ -162,10 +163,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Cannot_paginate_in_single_secondary_endpoint() + public async Task Cannot_paginate_in_secondary_resource() { // Arrange - BlogPost post = _fakers.BlogPost.Generate(); + BlogPost post = _fakers.BlogPost.GenerateOne(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -181,13 +182,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); - error.Detail.Should().Be("This query string parameter can only be used on a collection of resources (not on a single resource)."); - error.Source.ShouldNotBeNull(); + error.Title.Should().Be("The specified pagination is invalid."); + error.Detail.Should().Be($"{CollectionErrorMessage} Failed at position 1: ^page[size]"); + error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("page[size]"); } @@ -195,9 +196,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_paginate_in_scope_of_OneToMany_relationship() { // Arrange - List blogs = _fakers.Blog.Generate(3); - blogs[0].Posts = _fakers.BlogPost.Generate(2); - blogs[1].Posts = _fakers.BlogPost.Generate(2); + List blogs = _fakers.Blog.GenerateList(3); + blogs[0].Posts = _fakers.BlogPost.GenerateList(2); + blogs[1].Posts = _fakers.BlogPost.GenerateList(2); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -214,13 +215,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(2); - responseDocument.Included.ShouldHaveCount(2); + responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Included.Should().HaveCount(2); responseDocument.Included[0].Id.Should().Be(blogs[0].Posts[1].StringId); responseDocument.Included[1].Id.Should().Be(blogs[1].Posts[1].StringId); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.First.Should().Be($"{HostPrefix}/blogs?include=posts&page%5Bsize%5D=2,posts%3A1"); responseDocument.Links.Last.Should().Be($"{HostPrefix}/blogs?include=posts&page%5Bnumber%5D=2&page%5Bsize%5D=2,posts%3A1"); @@ -229,12 +230,12 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_paginate_in_scope_of_OneToMany_relationship_on_secondary_endpoint() + public async Task Can_paginate_in_scope_of_OneToMany_relationship_at_secondary_endpoint() { // Arrange - Blog blog = _fakers.Blog.Generate(); - blog.Owner = _fakers.WebAccount.Generate(); - blog.Owner.Posts = _fakers.BlogPost.Generate(2); + Blog blog = _fakers.Blog.GenerateOne(); + blog.Owner = _fakers.WebAccount.GenerateOne(); + blog.Owner.Posts = _fakers.BlogPost.GenerateList(2); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -250,11 +251,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.SingleValue.ShouldNotBeNull(); - responseDocument.Included.ShouldHaveCount(1); + responseDocument.Data.SingleValue.Should().NotBeNull(); + responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Id.Should().Be(blog.Owner.Posts[1].StringId); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); @@ -263,11 +264,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_paginate_OneToMany_relationship_on_relationship_endpoint() + public async Task Can_paginate_OneToMany_relationship_at_relationship_endpoint() { // Arrange - Blog blog = _fakers.Blog.Generate(); - blog.Posts = _fakers.BlogPost.Generate(4); + Blog blog = _fakers.Blog.GenerateOne(); + blog.Posts = _fakers.BlogPost.GenerateList(4); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -283,10 +284,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(blog.Posts[1].StringId); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.First.Should().Be($"{HostPrefix}/blogs/{blog.StringId}/relationships/posts?page%5Bsize%5D=1"); responseDocument.Links.Last.Should().Be($"{HostPrefix}/blogs/{blog.StringId}/relationships/posts?page%5Bnumber%5D=4&page%5Bsize%5D=1"); @@ -295,11 +296,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_paginate_OneToMany_relationship_on_relationship_endpoint_without_inverse_relationship() + public async Task Can_paginate_OneToMany_relationship_at_relationship_endpoint_without_inverse_relationship() { // Arrange - WebAccount? account = _fakers.WebAccount.Generate(); - account.LoginAttempts = _fakers.LoginAttempt.Generate(2); + WebAccount account = _fakers.WebAccount.GenerateOne(); + account.LoginAttempts = _fakers.LoginAttempt.GenerateList(2); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -315,26 +316,26 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(account.LoginAttempts[1].StringId); string basePath = $"{HostPrefix}/webAccounts/{account.StringId}/relationships/loginAttempts"; - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); - responseDocument.Links.First.Should().Be(basePath + "?page%5Bsize%5D=1"); + responseDocument.Links.First.Should().Be($"{basePath}?page%5Bsize%5D=1"); responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().Be(responseDocument.Links.First); - responseDocument.Links.Next.Should().Be(basePath + "?page%5Bnumber%5D=3&page%5Bsize%5D=1"); + responseDocument.Links.Next.Should().Be($"{basePath}?page%5Bnumber%5D=3&page%5Bsize%5D=1"); } [Fact] public async Task Can_paginate_in_scope_of_ManyToMany_relationship() { // Arrange - List posts = _fakers.BlogPost.Generate(2); - posts[0].Labels = _fakers.Label.Generate(2).ToHashSet(); - posts[1].Labels = _fakers.Label.Generate(2).ToHashSet(); + List posts = _fakers.BlogPost.GenerateList(2); + posts[0].Labels = _fakers.Label.GenerateSet(2); + posts[1].Labels = _fakers.Label.GenerateSet(2); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -351,13 +352,13 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(2); - responseDocument.Included.ShouldHaveCount(2); + responseDocument.Data.ManyValue.Should().HaveCount(2); + responseDocument.Included.Should().HaveCount(2); responseDocument.Included[0].Id.Should().Be(posts[0].Labels.ElementAt(1).StringId); responseDocument.Included[1].Id.Should().Be(posts[1].Labels.ElementAt(1).StringId); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.First.Should().Be($"{HostPrefix}/blogPosts?include=labels&page%5Bsize%5D=labels%3A1"); responseDocument.Links.Last.Should().Be(responseDocument.Links.First); @@ -366,11 +367,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Can_paginate_ManyToMany_relationship_on_relationship_endpoint() + public async Task Can_paginate_ManyToMany_relationship_at_relationship_endpoint() { // Arrange - BlogPost post = _fakers.BlogPost.Generate(); - post.Labels = _fakers.Label.Generate(4).ToHashSet(); + BlogPost post = _fakers.BlogPost.GenerateOne(); + post.Labels = _fakers.Label.GenerateSet(4); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -387,10 +388,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(post.Labels.ElementAt(1).StringId); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.First.Should().Be($"{HostPrefix}/blogPosts/{post.StringId}/relationships/labels?page%5Bsize%5D=1"); responseDocument.Links.Last.Should().Be($"{HostPrefix}/blogPosts/{post.StringId}/relationships/labels?page%5Bnumber%5D=4&page%5Bsize%5D=1"); @@ -402,10 +403,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Can_paginate_in_multiple_scopes() { // Arrange - List blogs = _fakers.Blog.Generate(2); - blogs[1].Owner = _fakers.WebAccount.Generate(); - blogs[1].Owner!.Posts = _fakers.BlogPost.Generate(2); - blogs[1].Owner!.Posts[1].Comments = _fakers.Comment.Generate(2).ToHashSet(); + List blogs = _fakers.Blog.GenerateList(2); + blogs[1].Owner = _fakers.WebAccount.GenerateOne(); + blogs[1].Owner!.Posts = _fakers.BlogPost.GenerateList(2); + blogs[1].Owner!.Posts[1].Comments = _fakers.Comment.GenerateSet(2); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -423,10 +424,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue.Should().HaveCount(1); responseDocument.Data.ManyValue[0].Id.Should().Be(blogs[1].StringId); - responseDocument.Included.ShouldHaveCount(3); + responseDocument.Included.Should().HaveCount(3); responseDocument.Included[0].Type.Should().Be("webAccounts"); responseDocument.Included[0].Id.Should().Be(blogs[1].Owner!.StringId); @@ -439,7 +440,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => const string linkPrefix = $"{HostPrefix}/blogs?include=owner.posts.comments"; - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.First.Should().Be($"{linkPrefix}&page%5Bsize%5D=1,owner.posts%3A1,owner.posts.comments%3A1"); responseDocument.Links.Last.Should().Be($"{linkPrefix}&page%5Bsize%5D=1,owner.posts%3A1,owner.posts.comments%3A1&page%5Bnumber%5D=2"); @@ -451,7 +452,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_paginate_in_unknown_scope() { // Arrange - const string route = $"/webAccounts?page[number]={Unknown.Relationship}:1"; + var parameterValue = new MarkedText($"^{Unknown.Relationship}:1", '^'); + string route = $"/webAccounts?page[number]={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -459,13 +461,13 @@ public async Task Cannot_paginate_in_unknown_scope() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' does not exist on resource type 'webAccounts'."); - error.Source.ShouldNotBeNull(); + error.Title.Should().Be("The specified pagination is invalid."); + error.Detail.Should().Be($"Field '{Unknown.Relationship}' does not exist on resource type 'webAccounts'. {parameterValue}"); + error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("page[number]"); } @@ -473,7 +475,8 @@ public async Task Cannot_paginate_in_unknown_scope() public async Task Cannot_paginate_in_unknown_nested_scope() { // Arrange - const string route = $"/webAccounts?page[size]=posts.{Unknown.Relationship}:1"; + var parameterValue = new MarkedText($"posts.^{Unknown.Relationship}:1", '^'); + string route = $"/webAccounts?page[size]={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -481,13 +484,13 @@ public async Task Cannot_paginate_in_unknown_nested_scope() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); - error.Detail.Should().Be($"Relationship '{Unknown.Relationship}' in 'posts.{Unknown.Relationship}' does not exist on resource type 'blogPosts'."); - error.Source.ShouldNotBeNull(); + error.Title.Should().Be("The specified pagination is invalid."); + error.Detail.Should().Be($"Field '{Unknown.Relationship}' does not exist on resource type 'blogPosts'. {parameterValue}"); + error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("page[size]"); } @@ -498,8 +501,9 @@ public async Task Uses_default_page_number_and_size() var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); options.DefaultPageSize = new PageSize(2); - Blog blog = _fakers.Blog.Generate(); - blog.Posts = _fakers.BlogPost.Generate(3); + Blog blog = _fakers.Blog.GenerateOne(); + blog.Posts = _fakers.BlogPost.GenerateList(3); + blog.Posts.ForEach(post => post.Labels = _fakers.Label.GenerateSet(3)); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -507,7 +511,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/blogs/{blog.StringId}/posts"; + string route = $"/blogs/{blog.StringId}/posts?include=labels"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -515,27 +519,29 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(2); + responseDocument.Data.ManyValue.Should().HaveCount(2); responseDocument.Data.ManyValue[0].Id.Should().Be(blog.Posts[0].StringId); responseDocument.Data.ManyValue[1].Id.Should().Be(blog.Posts[1].StringId); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Included.Should().HaveCount(4); + + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.First.Should().Be(responseDocument.Links.Self); - responseDocument.Links.Last.Should().Be($"{HostPrefix}{route}?page%5Bnumber%5D=2"); + responseDocument.Links.Last.Should().Be($"{responseDocument.Links.Self}&page%5Bnumber%5D=2"); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().Be(responseDocument.Links.Last); } [Fact] - public async Task Returns_all_resources_when_paging_is_disabled() + public async Task Returns_all_resources_when_pagination_is_disabled() { // Arrange var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); options.DefaultPageSize = null; - Blog blog = _fakers.Blog.Generate(); - blog.Posts = _fakers.BlogPost.Generate(25); + Blog blog = _fakers.Blog.GenerateOne(); + blog.Posts = _fakers.BlogPost.GenerateList(25); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -551,9 +557,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(25); + responseDocument.Data.ManyValue.Should().HaveCount(25); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); @@ -569,10 +575,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Renders_correct_top_level_links_for_page_number(int pageNumber, int? firstLink, int? lastLink, int? prevLink, int? nextLink) { // Arrange - WebAccount account = _fakers.WebAccount.Generate(); + WebAccount account = _fakers.WebAccount.GenerateOne(); const int totalCount = 3 * DefaultPageSize + 3; - List posts = _fakers.BlogPost.Generate(totalCount); + List posts = _fakers.BlogPost.GenerateList(totalCount); foreach (BlogPost post in posts) { @@ -586,9 +592,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string routePrefix = $"/blogPosts?filter=equals(author.userName,'{account.UserName}')" + - "&fields[webAccounts]=userName&include=author&sort=id&foo=bar,baz"; - + string routePrefix = $"/blogPosts?filter=equals(author.userName,'{account.UserName}')&fields[webAccounts]=userName&include=author&sort=id&foo=bar,baz"; string route = $"{routePrefix}&page[number]={pageNumber}"; // Act @@ -597,8 +601,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.ShouldNotBeNull(); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Should().NotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); if (firstLink != null) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithoutTotalCountTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithoutTotalCountTests.cs index 57215e12a1..1bcfb0c6f5 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithoutTotalCountTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/PaginationWithoutTotalCountTests.cs @@ -44,7 +44,7 @@ public async Task Hides_pagination_links_when_unconstrained_page_size() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.First.Should().BeNull(); responseDocument.Links.Last.Should().BeNull(); @@ -73,7 +73,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.First.Should().Be($"{HostPrefix}/blogPosts?page%5Bsize%5D=8&foo=bar"); responseDocument.Links.Last.Should().BeNull(); @@ -99,7 +99,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.First.Should().Be($"{HostPrefix}/blogPosts?foo=bar"); responseDocument.Links.Last.Should().BeNull(); @@ -111,7 +111,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Renders_pagination_links_when_page_number_is_specified_in_query_string_with_partially_filled_page() { // Arrange - List posts = _fakers.BlogPost.Generate(12); + List posts = _fakers.BlogPost.GenerateList(12); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -130,7 +130,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.ManyValue.Should().HaveCountLessThan(DefaultPageSize); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.First.Should().Be($"{HostPrefix}/blogPosts?foo=bar"); responseDocument.Links.Last.Should().BeNull(); @@ -142,7 +142,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Renders_pagination_links_when_page_number_is_specified_in_query_string_with_full_page() { // Arrange - List posts = _fakers.BlogPost.Generate(DefaultPageSize * 3); + List posts = _fakers.BlogPost.GenerateList(DefaultPageSize * 3); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -159,9 +159,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(DefaultPageSize); + responseDocument.Data.ManyValue.Should().HaveCount(DefaultPageSize); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.First.Should().Be($"{HostPrefix}/blogPosts?foo=bar"); responseDocument.Links.Last.Should().BeNull(); @@ -170,11 +170,11 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } [Fact] - public async Task Renders_pagination_links_when_page_number_is_specified_in_query_string_with_full_page_on_secondary_endpoint() + public async Task Renders_pagination_links_when_page_number_is_specified_in_query_string_with_full_page_at_secondary_endpoint() { // Arrange - WebAccount account = _fakers.WebAccount.Generate(); - account.Posts = _fakers.BlogPost.Generate(DefaultPageSize * 3); + WebAccount account = _fakers.WebAccount.GenerateOne(); + account.Posts = _fakers.BlogPost.GenerateList(DefaultPageSize * 3); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -190,9 +190,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); - responseDocument.Data.ManyValue.ShouldHaveCount(DefaultPageSize); + responseDocument.Data.ManyValue.Should().HaveCount(DefaultPageSize); - responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.Should().NotBeNull(); responseDocument.Links.Self.Should().Be($"{HostPrefix}{route}"); responseDocument.Links.First.Should().Be($"{HostPrefix}/webAccounts/{account.StringId}/posts?foo=bar"); responseDocument.Links.Last.Should().BeNull(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationTests.cs index bcbd864d65..687bffdc19 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationTests.cs @@ -30,7 +30,8 @@ public RangeValidationTests(IntegrationTestContext(route); @@ -38,13 +39,13 @@ public async Task Cannot_use_negative_page_number() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); - error.Detail.Should().Be("Page number cannot be negative or zero."); - error.Source.ShouldNotBeNull(); + error.Title.Should().Be("The specified pagination is invalid."); + error.Detail.Should().Be($"Page number cannot be negative or zero. {parameterValue}"); + error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("page[number]"); } @@ -52,7 +53,8 @@ public async Task Cannot_use_negative_page_number() public async Task Cannot_use_zero_page_number() { // Arrange - const string route = "/blogs?page[number]=0"; + var parameterValue = new MarkedText("^0", '^'); + string route = $"/blogs?page[number]={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -60,13 +62,13 @@ public async Task Cannot_use_zero_page_number() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); - error.Detail.Should().Be("Page number cannot be negative or zero."); - error.Source.ShouldNotBeNull(); + error.Title.Should().Be("The specified pagination is invalid."); + error.Detail.Should().Be($"Page number cannot be negative or zero. {parameterValue}"); + error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("page[number]"); } @@ -87,7 +89,7 @@ public async Task Can_use_positive_page_number() public async Task Returns_empty_set_of_resources_when_page_number_is_too_high() { // Arrange - List blogs = _fakers.Blog.Generate(3); + List blogs = _fakers.Blog.GenerateList(3); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -111,7 +113,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => public async Task Cannot_use_negative_page_size() { // Arrange - const string route = "/blogs?page[size]=-1"; + var parameterValue = new MarkedText("^-1", '^'); + string route = $"/blogs?page[size]={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -119,13 +122,13 @@ public async Task Cannot_use_negative_page_size() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); - error.Detail.Should().Be("Page size cannot be negative."); - error.Source.ShouldNotBeNull(); + error.Title.Should().Be("The specified pagination is invalid."); + error.Detail.Should().Be($"Page size cannot be negative. {parameterValue}"); + error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("page[size]"); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs index cdbb9ea4be..18141b6758 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/Pagination/RangeValidationWithMaximumTests.cs @@ -59,7 +59,8 @@ public async Task Cannot_use_page_number_over_maximum() { // Arrange const int pageNumber = MaximumPageNumber + 1; - string route = $"/blogs?page[number]={pageNumber}"; + var parameterValue = new MarkedText($"^{pageNumber}", '^'); + string route = $"/blogs?page[number]={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -67,13 +68,13 @@ public async Task Cannot_use_page_number_over_maximum() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); - error.Detail.Should().Be($"Page number cannot be higher than {MaximumPageNumber}."); - error.Source.ShouldNotBeNull(); + error.Title.Should().Be("The specified pagination is invalid."); + error.Detail.Should().Be($"Page number cannot be higher than {MaximumPageNumber}. {parameterValue}"); + error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("page[number]"); } @@ -81,7 +82,8 @@ public async Task Cannot_use_page_number_over_maximum() public async Task Cannot_use_zero_page_size() { // Arrange - const string route = "/blogs?page[size]=0"; + var parameterValue = new MarkedText("^0", '^'); + string route = $"/blogs?page[size]={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -89,13 +91,13 @@ public async Task Cannot_use_zero_page_size() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); - error.Detail.Should().Be("Page size cannot be unconstrained."); - error.Source.ShouldNotBeNull(); + error.Title.Should().Be("The specified pagination is invalid."); + error.Detail.Should().Be($"Page size cannot be unconstrained. {parameterValue}"); + error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("page[size]"); } @@ -132,7 +134,8 @@ public async Task Cannot_use_page_size_over_maximum() { // Arrange const int pageSize = MaximumPageSize + 1; - string route = $"/blogs?page[size]={pageSize}"; + var parameterValue = new MarkedText($"^{pageSize}", '^'); + string route = $"/blogs?page[size]={parameterValue.Text}"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -140,13 +143,13 @@ public async Task Cannot_use_page_size_over_maximum() // Assert httpResponse.ShouldHaveStatusCode(HttpStatusCode.BadRequest); - responseDocument.Errors.ShouldHaveCount(1); + responseDocument.Errors.Should().HaveCount(1); ErrorObject error = responseDocument.Errors[0]; error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("The specified paging is invalid."); - error.Detail.Should().Be($"Page size cannot be higher than {MaximumPageSize}."); - error.Source.ShouldNotBeNull(); + error.Title.Should().Be("The specified pagination is invalid."); + error.Detail.Should().Be($"Page size cannot be higher than {MaximumPageSize}. {parameterValue}"); + error.Source.Should().NotBeNull(); error.Source.Parameter.Should().Be("page[size]"); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs index 1e240f8e8d..0a7102c3d2 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringDbContext.cs @@ -1,12 +1,14 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; +using TestBuildingBlocks; // @formatter:wrap_chained_method_calls chop_always namespace JsonApiDotNetCoreTests.IntegrationTests.QueryStrings; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -public sealed class QueryStringDbContext : DbContext +public sealed class QueryStringDbContext(DbContextOptions options) + : TestableDbContext(options) { public DbSet Blogs => Set(); public DbSet Posts => Set(); @@ -20,11 +22,7 @@ public sealed class QueryStringDbContext : DbContext public DbSet LoginAttempts => Set(); public DbSet Calendars => Set(); public DbSet Appointments => Set(); - - public QueryStringDbContext(DbContextOptions options) - : base(options) - { - } + public DbSet Reminders => Set(); protected override void OnModelCreating(ModelBuilder builder) { @@ -36,5 +34,7 @@ protected override void OnModelCreating(ModelBuilder builder) .HasOne(man => man.Wife) .WithOne(woman => woman.Husband) .HasForeignKey(); + + base.OnModelCreating(builder); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs index ab7fc4b77e..36e5e454e7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringFakers.cs @@ -1,74 +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 +internal sealed class QueryStringFakers { - 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() + .MakeDeterministic() + .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() + .MakeDeterministic() + .RuleFor(blogPost => blogPost.Caption, faker => faker.Lorem.Sentence()) + .RuleFor(blogPost => blogPost.Url, faker => faker.Internet.Url())); - private readonly Lazy> _lazyLabelFaker = new(() => - new Faker