diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 60cd78544c..9d271bea8d 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,28 +3,39 @@ "isRoot": true, "tools": { "jetbrains.resharper.globaltools": { - "version": "2021.1.3", + "version": "2024.3.6", "commands": [ "jb" - ] + ], + "rollForward": false }, "regitlint": { - "version": "2.1.4", + "version": "6.3.13", "commands": [ "regitlint" - ] - }, - "codecov.tool": { - "version": "1.13.0", - "commands": [ - "codecov" - ] + ], + "rollForward": false }, "dotnet-reportgenerator-globaltool": { - "version": "4.8.9", + "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 f4fa0f699b..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. @@ -34,14 +32,14 @@ Explain the problem and include additional details to help maintainers reproduce This section guides you through submitting an enhancement suggestion, including completely new features and minor improvements to existing functionality. Following these guidelines helps maintainers and the community understand your suggestion and find related suggestions. Before creating enhancement suggestions: -- Check the [documentation](https://www.jsonapi.net/usage/resources/index.html) and [integration tests](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreExampleTests/IntegrationTests) for existing features. You might discover the enhancement is already available. +- Check the [documentation](https://www.jsonapi.net/usage/resources/index.html) and [integration tests](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/test/JsonApiDotNetCoreTests/IntegrationTests) for existing features. You might discover the enhancement is already available. - Perform a search to see if the feature has already been reported. If it has and the issue is still open, add a comment to the existing issue instead of opening a new one. When you are creating an enhancement suggestion, please include as many details as possible. Fill in the template, including the steps that you imagine you would take if the feature you're requesting existed. - **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: -- `inspectcode.ps1`: Scans the code for style violations and opens the result in your web browser. -- `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,6 +86,40 @@ 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` +- Create a GitHub release +- 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) - Checkout the version you want to apply the feature on top of and create a new branch to release the new version: @@ -95,8 +127,8 @@ 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 the csproj -- Make any other compatibility, documentation or tooling related changes +- 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 - Open a PR back to master with any other additions 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 ed1eea959b..1a1c618dd7 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,7 +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 -- [ ] Created issue to update [Templates](https://github.com/json-api-dotnet/Templates/issues/new): {ISSUE_NUMBER} 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 ee1dd68cfb..1c369bd1af 100644 --- a/Build.ps1 +++ b/Build.ps1 @@ -1,110 +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') - dotnet jb inspectcode JsonApiDotNetCore.sln --output="$outputPath" --profile=WarningSeverities.DotSettings --properties:Configuration=Release --severity=WARNING --verbosity=WARN -dsl=GlobalAll -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 --jb --profile --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" => "pre-0123". - if ($env:APPVEYOR_BUILD_NUMBER) { - $revision = "{0:D4}" -f [convert]::ToInt32($env:APPVEYOR_BUILD_NUMBER, 10) - $versionSuffix = "pre-$revision" - } - else { - $versionSuffix = "pre-0001" - } - } - - if ([string]::IsNullOrWhitespace($versionSuffix)) { - dotnet pack .\src\JsonApiDotNetCore -c Release -o .\artifacts - } - else { - dotnet pack .\src\JsonApiDotNetCore -c Release -o .\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 acd0856299..6d5453159a 100644 --- a/CSharpGuidelinesAnalyzer.config +++ b/CSharpGuidelinesAnalyzer.config @@ -1,5 +1,5 @@ - + diff --git a/CodingGuidelines.ruleset b/CodingGuidelines.ruleset index 05545fb55c..b29d7423b4 100644 --- a/CodingGuidelines.ruleset +++ b/CodingGuidelines.ruleset @@ -1,31 +1,54 @@  - + + + - - - - - - + + - - - + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index 49eb522e60..1ef255f56e 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,31 +1,61 @@ - netcoreapp3.1 - 3.1.* - 3.1.* - 3.1.* + 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 - - - 33.0.2 - 3.0.3 - 5.10.3 - 4.16.1 - 2.4.* - 16.10.0 + + true + + + $(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 3d697e1ee5..793c01950d 100644 --- a/JsonApiDotNetCore.sln +++ b/JsonApiDotNetCore.sln @@ -1,6 +1,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.28606.126 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31919.166 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{7A2B7ADD-ECB5-4D00-AA6A-D45BD11C97CF}" EndProject @@ -10,15 +10,21 @@ 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}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarks", "benchmarks", "{076E1AE4-FD25-4684-B826-CAAE37FEA0AA}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JsonApiDotNetCoreExampleTests", "test\JsonApiDotNetCoreExampleTests\JsonApiDotNetCoreExampleTests.csproj", "{CAF331F8-9255-4D72-A1A8-A54141E99F1E}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GettingStarted", "src\Examples\GettingStarted\GettingStarted.csproj", "{067FFD7A-C66B-473D-8471-37F5C95DF61C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JsonApiDotNetCoreTests", "test\JsonApiDotNetCoreTests\JsonApiDotNetCoreTests.csproj", "{CAF331F8-9255-4D72-A1A8-A54141E99F1E}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NoEntityFrameworkTests", "test\NoEntityFrameworkTests\NoEntityFrameworkTests.csproj", "{4F15A8F8-5BC6-45A1-BC51-03F921B726A4}" EndProject @@ -36,14 +42,44 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ReportsExample", "src\Examp EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JsonApiDotNetCore", "src\JsonApiDotNetCore\JsonApiDotNetCore.csproj", "{21D27239-138D-4604-8E49-DCBE41BCE4C8}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GettingStarted", "src\Examples\GettingStarted\GettingStarted.csproj", "{067FFD7A-C66B-473D-8471-37F5C95DF61C}" -EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MultiDbContextExample", "src\Examples\MultiDbContextExample\MultiDbContextExample.csproj", "{6CAFDDBE-00AB-4784-801B-AB419C3C3A26}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MultiDbContextTests", "test\MultiDbContextTests\MultiDbContextTests.csproj", "{EC3202C6-1D4C-4B14-A599-B9D3F27FE3BA}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestBuildingBlocks", "test\TestBuildingBlocks\TestBuildingBlocks.csproj", "{210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}" 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}") = "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 @@ -54,6 +90,18 @@ Global Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|x64.ActiveCfg = Debug|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|x64.Build.0 = Debug|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|x86.ActiveCfg = Debug|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|x86.Build.0 = Debug|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|Any CPU.Build.0 = Release|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|x64.ActiveCfg = Release|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|x64.Build.0 = Release|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|x86.ActiveCfg = Release|Any CPU + {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|x86.Build.0 = Release|Any CPU {CAF331F8-9255-4D72-A1A8-A54141E99F1E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CAF331F8-9255-4D72-A1A8-A54141E99F1E}.Debug|Any CPU.Build.0 = Debug|Any CPU {CAF331F8-9255-4D72-A1A8-A54141E99F1E}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -162,18 +210,6 @@ Global {21D27239-138D-4604-8E49-DCBE41BCE4C8}.Release|x64.Build.0 = Release|Any CPU {21D27239-138D-4604-8E49-DCBE41BCE4C8}.Release|x86.ActiveCfg = Release|Any CPU {21D27239-138D-4604-8E49-DCBE41BCE4C8}.Release|x86.Build.0 = Release|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|x64.ActiveCfg = Debug|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|x64.Build.0 = Debug|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|x86.ActiveCfg = Debug|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Debug|x86.Build.0 = Debug|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|Any CPU.Build.0 = Release|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|x64.ActiveCfg = Release|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|x64.Build.0 = Release|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|x86.ActiveCfg = Release|Any CPU - {067FFD7A-C66B-473D-8471-37F5C95DF61C}.Release|x86.Build.0 = Release|Any CPU {6CAFDDBE-00AB-4784-801B-AB419C3C3A26}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6CAFDDBE-00AB-4784-801B-AB419C3C3A26}.Debug|Any CPU.Build.0 = Debug|Any CPU {6CAFDDBE-00AB-4784-801B-AB419C3C3A26}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -210,6 +246,198 @@ Global {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Release|x64.Build.0 = Release|Any CPU {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Release|x86.ActiveCfg = Release|Any CPU {210FD61E-FF5D-4CEE-8E0D-C739ECCCBA21}.Release|x86.Build.0 = Release|Any CPU + {952C0FDE-AFC8-455C-986F-6CC882ED8953}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {952C0FDE-AFC8-455C-986F-6CC882ED8953}.Debug|Any CPU.Build.0 = Debug|Any CPU + {952C0FDE-AFC8-455C-986F-6CC882ED8953}.Debug|x64.ActiveCfg = Debug|Any CPU + {952C0FDE-AFC8-455C-986F-6CC882ED8953}.Debug|x64.Build.0 = Debug|Any CPU + {952C0FDE-AFC8-455C-986F-6CC882ED8953}.Debug|x86.ActiveCfg = Debug|Any CPU + {952C0FDE-AFC8-455C-986F-6CC882ED8953}.Debug|x86.Build.0 = Debug|Any CPU + {952C0FDE-AFC8-455C-986F-6CC882ED8953}.Release|Any CPU.ActiveCfg = Release|Any CPU + {952C0FDE-AFC8-455C-986F-6CC882ED8953}.Release|Any CPU.Build.0 = Release|Any CPU + {952C0FDE-AFC8-455C-986F-6CC882ED8953}.Release|x64.ActiveCfg = Release|Any CPU + {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 + {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 + {0E0B5C51-F7E2-4F40-A4E4-DED0E9731DC9}.Debug|x64.Build.0 = Debug|Any CPU + {0E0B5C51-F7E2-4F40-A4E4-DED0E9731DC9}.Debug|x86.ActiveCfg = Debug|Any CPU + {0E0B5C51-F7E2-4F40-A4E4-DED0E9731DC9}.Debug|x86.Build.0 = Debug|Any CPU + {0E0B5C51-F7E2-4F40-A4E4-DED0E9731DC9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0E0B5C51-F7E2-4F40-A4E4-DED0E9731DC9}.Release|Any CPU.Build.0 = Release|Any CPU + {0E0B5C51-F7E2-4F40-A4E4-DED0E9731DC9}.Release|x64.ActiveCfg = Release|Any CPU + {0E0B5C51-F7E2-4F40-A4E4-DED0E9731DC9}.Release|x64.Build.0 = Release|Any CPU + {0E0B5C51-F7E2-4F40-A4E4-DED0E9731DC9}.Release|x86.ActiveCfg = Release|Any CPU + {0E0B5C51-F7E2-4F40-A4E4-DED0E9731DC9}.Release|x86.Build.0 = Release|Any CPU + {83FF097C-C8C6-477B-9FAB-DF99B84978B5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {83FF097C-C8C6-477B-9FAB-DF99B84978B5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {83FF097C-C8C6-477B-9FAB-DF99B84978B5}.Debug|x64.ActiveCfg = Debug|Any CPU + {83FF097C-C8C6-477B-9FAB-DF99B84978B5}.Debug|x64.Build.0 = Debug|Any CPU + {83FF097C-C8C6-477B-9FAB-DF99B84978B5}.Debug|x86.ActiveCfg = Debug|Any CPU + {83FF097C-C8C6-477B-9FAB-DF99B84978B5}.Debug|x86.Build.0 = Debug|Any CPU + {83FF097C-C8C6-477B-9FAB-DF99B84978B5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {83FF097C-C8C6-477B-9FAB-DF99B84978B5}.Release|Any CPU.Build.0 = Release|Any CPU + {83FF097C-C8C6-477B-9FAB-DF99B84978B5}.Release|x64.ActiveCfg = Release|Any CPU + {83FF097C-C8C6-477B-9FAB-DF99B84978B5}.Release|x64.Build.0 = Release|Any CPU + {83FF097C-C8C6-477B-9FAB-DF99B84978B5}.Release|x86.ActiveCfg = Release|Any CPU + {83FF097C-C8C6-477B-9FAB-DF99B84978B5}.Release|x86.Build.0 = Release|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Debug|x64.ActiveCfg = Debug|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Debug|x64.Build.0 = Debug|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Debug|x86.ActiveCfg = Debug|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Debug|x86.Build.0 = Debug|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Release|Any CPU.Build.0 = Release|Any CPU + {60334658-BE51-43B3-9C4D-F2BBF56C89CE}.Release|x64.ActiveCfg = Release|Any CPU + {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 @@ -228,6 +456,22 @@ Global {6CAFDDBE-00AB-4784-801B-AB419C3C3A26} = {026FBC6C-AF76-4568-9B87-EC73457899FD} {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} + {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 d22ebc6887..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 @@ -27,6 +20,8 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$, $NAME$); SUGGESTION SUGGESTION SUGGESTION + WARNING + WARNING SUGGESTION SUGGESTION SUGGESTION @@ -52,12 +47,21 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$, $NAME$); WARNING WARNING WARNING + DO_NOT_SHOW WARNING + SUGGESTION + HINT + WARNING + SUGGESTION DO_NOT_SHOW HINT SUGGESTION - SUGGESTION + SUGGESTION + SUGGESTION + WARNING + WARNING WARNING + SUGGESTION SUGGESTION SUGGESTION DO_NOT_SHOW @@ -69,6 +73,9 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$, $NAME$); SUGGESTION SUGGESTION SUGGESTION + WARNING + DO_NOT_SHOW + WARNING WARNING WARNING WARNING @@ -76,9 +83,18 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$, $NAME$); SUGGESTION WARNING HINT + WARNING 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" /><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><EmbraceInRegion>False</EmbraceInRegion><RegionName></RegionName></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 @@ -86,16 +102,19 @@ JsonApiDotNetCore.ArgumentGuard.NotNull($EXPR$, $NAME$); Required Conditional False + False 1 1 1 1 + False True True True True True True + INDENT 1 1 False @@ -103,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 @@ -121,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 @@ -561,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 @@ -586,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 @@ -601,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 @@ -614,19 +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 49847f8429..5ab49fae35 100644 --- a/README.md +++ b/README.md @@ -1,116 +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/) +[![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, 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 + +- [JsonApiDotNetCore documentation](https://www.jsonapi.net/) +- [The JSON:API specification](https://jsonapi.org/format/) +- [JsonApiDotNetCore roadmap](ROADMAP.md) + +### Samples + +- 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 -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: - -- [What is JSON:API and why should I use it?](https://nordicapis.com/the-benefits-of-using-json-api/) -- [The JSON:API specification](http://jsonapi.org/format/) -- Demo [Video](https://youtu.be/KAMuo6K7VcE), [Blog](https://dev.to/wunki/getting-started-5dkl) -- [Our documentation](https://www.jsonapi.net/) -- [Check out the example projects](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/master/src/Examples) -- [Embercasts: Full Stack Ember with ASP.NET Core](https://www.embercasts.com/course/full-stack-ember-with-dotnet/watch/whats-in-this-course-cs) -- [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# -public class Article : Identifiable -{ - [Attr] - public string Name { get; set; } -} -``` - -### Controllers - -```c# -public class ArticlesController : JsonApiController
-{ - public ArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService
resourceService,) - : base(options, loggerFactory, resourceService) - { - } -} -``` - -### Middleware - -```c# -public class Startup -{ - public IServiceProvider ConfigureServices(IServiceCollection services) - { - services.AddJsonApi(); - } - - public void Configure(IApplicationBuilder app) - { - app.UseRouting(); - app.UseJsonApi(); - app.UseEndpoints(endpoints => endpoints.MapControllers()); - } -} -``` +- [Ember.js Todo List App](https://github.com/json-api-dotnet/TodoListExample) +- [Performance Reports](https://github.com/json-api-dotnet/PerformanceReports) ## Compatibility -A lot of changes were introduced in v4. The following chart should help you pick the best version, based on your environment. +The following chart should help you pick the best version, based on your environment. See also our [versioning policy](./VERSIONING_POLICY.md). -| .NET Version | EF Core Version | JsonApiDotNetCore Version | -| ----------------- | --------------- | ------------------------- | -| .NET Core 2.x | 2.x | v3.x | -| .NET Core 3.1 | 3.1, 5 | v4 | -| .NET 5 | 5 | v4 | +| 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 | +## Trying out the latest build -## Contributing +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: -Have a question, found a bug or want to submit code changes? See our [contributing guidelines](./.github/CONTRIBUTING.md). +1. Create a `nuget.config` file in the same directory as your .sln file, with the following contents: + ```xml + + + + + + + + ``` -## Trying out the latest build +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. -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: +## Contributing -* 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` +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: @@ -118,10 +267,10 @@ 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 -run-docker-postgres.ps1 +pwsh run-docker-postgres.ps1 ``` And then to run the tests: @@ -130,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 -Build.ps1 +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 536bda5ea3..d73797824f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,31 +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. - -## v4.x -In December 2020, we released v4.0 stable after a long time. From now on, we'd like to release new features and bugfixes often. -In subsequent v4.x releases, we intend to implement the next features in non-breaking ways. - -- Codebase improvements (refactor tests [#715](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/715), coding guidelines [#835](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/835) [#290](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/290), cibuild [#908](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/908)) -- Bulk/batch support (atomic:operations) [#936](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/936) -- Write callbacks [#934](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/934) -- ETags [#933](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/933) -- Optimistic Concurrency [#350](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/350) -- Configuration validation [#414](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/414) [#170](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/170) - -## vNext -We have interest in the following topics for future versions. -Some cannot be done in v4.x the way we'd like without introducing breaking changes. -Others require more exploration first, or depend on other features. - -- Resource inheritance [#844](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/844) -- Split into multiple NuGet packages [#730](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/730) [#661](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/661) [#292](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/292) -- System.Text.Json [#664](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/664) -- EF Core 5 Many-to-many relationships [#935](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/935) +> 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 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) +- Idempotency [#1132](https://github.com/json-api-dotnet/JsonApiDotNetCore/pull/1132) - Fluent API [#776](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/776) -- Auto-generated controllers [#732](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/732) [#365](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/365) -- Serialization, discovery and documentation [#661](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/661) [#259](https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/259) ## Feedback @@ -36,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 e5b0f781ad..1ad1455837 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,98 +1,12 @@ -image: - - Ubuntu - - Visual Studio 2019 +image: Visual Studio 2022 version: '{build}' -stack: postgresql - -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 2019 - services: - - postgresql101 - # 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 - 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: OBYPCgp3WCuwkDRMuZ9a4QcBdTja/lqlUwZ+Yl5VHqooSJRVTYKP5y15XK0fuHsZ - on: - branch: master - appveyor_repo_tag: true - - provider: NuGet - skip_symbols: false - api_key: - secure: OBYPCgp3WCuwkDRMuZ9a4QcBdTja/lqlUwZ+Yl5VHqooSJRVTYKP5y15XK0fuHsZ - on: - branch: /release\/.+/ - appveyor_repo_tag: true - -build_script: -- pwsh: dotnet --version -- pwsh: .\Build.ps1 - +build: off test: off +deploy: off diff --git a/benchmarks/BenchmarkResource.cs b/benchmarks/BenchmarkResource.cs deleted file mode 100644 index acc1511844..0000000000 --- a/benchmarks/BenchmarkResource.cs +++ /dev/null @@ -1,16 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace Benchmarks -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class BenchmarkResource : Identifiable - { - [Attr(PublicName = BenchmarkResourcePublicNames.NameAttr)] - public string Name { get; set; } - - [HasOne] - public SubResource Child { get; set; } - } -} diff --git a/benchmarks/BenchmarkResourcePublicNames.cs b/benchmarks/BenchmarkResourcePublicNames.cs deleted file mode 100644 index 84b63e7668..0000000000 --- a/benchmarks/BenchmarkResourcePublicNames.cs +++ /dev/null @@ -1,10 +0,0 @@ -#pragma warning disable AV1008 // Class should not be static - -namespace Benchmarks -{ - internal static class BenchmarkResourcePublicNames - { - public const string NameAttr = "full-name"; - public const string Type = "simple-types"; - } -} diff --git a/benchmarks/Benchmarks.csproj b/benchmarks/Benchmarks.csproj index 4b19516001..90d53461d2 100644 --- a/benchmarks/Benchmarks.csproj +++ b/benchmarks/Benchmarks.csproj @@ -1,15 +1,20 @@ Exe - $(NetCoreAppVersion) + net9.0 + true + + - - + + + + diff --git a/benchmarks/DependencyFactory.cs b/benchmarks/DependencyFactory.cs deleted file mode 100644 index 7ecbafffbc..0000000000 --- a/benchmarks/DependencyFactory.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Benchmarks -{ - internal sealed class DependencyFactory - { - public IResourceGraph CreateResourceGraph(IJsonApiOptions options) - { - var builder = new ResourceGraphBuilder(options, NullLoggerFactory.Instance); - builder.Add(BenchmarkResourcePublicNames.Type); - return builder.Build(); - } - } -} diff --git a/benchmarks/Deserialization/DeserializationBenchmarkBase.cs b/benchmarks/Deserialization/DeserializationBenchmarkBase.cs new file mode 100644 index 0000000000..4febabba1a --- /dev/null +++ b/benchmarks/Deserialization/DeserializationBenchmarkBase.cs @@ -0,0 +1,136 @@ +using System.ComponentModel.Design; +using System.Text.Json; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.JsonConverters; +using JsonApiDotNetCore.Serialization.Request.Adapters; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Benchmarks.Deserialization; + +public abstract class DeserializationBenchmarkBase : IDisposable +{ + private readonly ServiceContainer _serviceProvider = new(); + + protected JsonSerializerOptions SerializerReadOptions { get; } + protected DocumentAdapter DocumentAdapter { get; } + + protected DeserializationBenchmarkBase() + { + var options = new JsonApiOptions(); + IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); + options.SerializerOptions.Converters.Add(new ResourceObjectConverter(resourceGraph)); + SerializerReadOptions = ((IJsonApiOptions)options).SerializerReadOptions; + + var resourceFactory = new ResourceFactory(_serviceProvider); + var resourceDefinitionAccessor = new ResourceDefinitionAccessor(resourceGraph, _serviceProvider); + + _serviceProvider.AddService(typeof(IResourceDefinitionAccessor), resourceDefinitionAccessor); + _serviceProvider.AddService(typeof(IResourceDefinition), new JsonApiResourceDefinition(resourceGraph)); + + // ReSharper disable once VirtualMemberCallInConstructor + JsonApiRequest request = CreateJsonApiRequest(resourceGraph); + var targetedFields = new TargetedFields(); + + var resourceIdentifierObjectAdapter = new ResourceIdentifierObjectAdapter(resourceGraph, resourceFactory); + var relationshipDataAdapter = new RelationshipDataAdapter(resourceIdentifierObjectAdapter); + var resourceObjectAdapter = new ResourceObjectAdapter(resourceGraph, resourceFactory, options, relationshipDataAdapter); + var resourceDataAdapter = new ResourceDataAdapter(resourceDefinitionAccessor, resourceObjectAdapter); + + var atomicReferenceAdapter = new AtomicReferenceAdapter(resourceGraph, resourceFactory); + var atomicOperationResourceDataAdapter = new ResourceDataInOperationsRequestAdapter(resourceDefinitionAccessor, resourceObjectAdapter); + + var atomicOperationObjectAdapter = new AtomicOperationObjectAdapter(options, atomicReferenceAdapter, + atomicOperationResourceDataAdapter, relationshipDataAdapter); + + var resourceDocumentAdapter = new DocumentInResourceOrRelationshipRequestAdapter(options, resourceDataAdapter, relationshipDataAdapter); + var operationsDocumentAdapter = new DocumentInOperationsRequestAdapter(options, atomicOperationObjectAdapter); + + DocumentAdapter = new DocumentAdapter(request, targetedFields, resourceDocumentAdapter, operationsDocumentAdapter); + } + + 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 + { + [Attr] + public bool Attribute01 { get; set; } + + [Attr] + public char Attribute02 { get; set; } + + [Attr] + public ulong? Attribute03 { get; set; } + + [Attr] + public decimal Attribute04 { get; set; } + + [Attr] + public float? Attribute05 { get; set; } + + [Attr] + public string Attribute06 { get; set; } = null!; + + [Attr] + public DateTime? Attribute07 { get; set; } + + [Attr] + public DateTimeOffset? Attribute08 { get; set; } + + [Attr] + public TimeSpan? Attribute09 { get; set; } + + [Attr] + public DayOfWeek Attribute10 { get; set; } + + [HasOne] + public IncomingResource Single1 { get; set; } = null!; + + [HasOne] + public IncomingResource Single2 { get; set; } = null!; + + [HasOne] + public IncomingResource Single3 { get; set; } = null!; + + [HasOne] + public IncomingResource Single4 { get; set; } = null!; + + [HasOne] + public IncomingResource Single5 { get; set; } = null!; + + [HasMany] + public ISet Multi1 { get; set; } = null!; + + [HasMany] + public ISet Multi2 { get; set; } = null!; + + [HasMany] + public ISet Multi3 { get; set; } = null!; + + [HasMany] + public ISet Multi4 { get; set; } = null!; + + [HasMany] + public ISet Multi5 { get; set; } = null!; + } +} diff --git a/benchmarks/Deserialization/OperationsDeserializationBenchmarks.cs b/benchmarks/Deserialization/OperationsDeserializationBenchmarks.cs new file mode 100644 index 0000000000..99adce73cb --- /dev/null +++ b/benchmarks/Deserialization/OperationsDeserializationBenchmarks.cs @@ -0,0 +1,284 @@ +using System.Text.Json; +using BenchmarkDotNet.Attributes; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Serialization.Objects; + +namespace Benchmarks.Deserialization; + +[MarkdownExporter] +[MemoryDiagnoser] +// ReSharper disable once ClassCanBeSealed.Global +public class OperationsDeserializationBenchmarks : DeserializationBenchmarkBase +{ + private static readonly string RequestBody = JsonSerializer.Serialize(new + { + atomic__operations = new object[] + { + new + { + op = "add", + data = new + { + type = "incomingResources", + lid = "a-1", + attributes = new + { + attribute01 = true, + attribute02 = 'A', + attribute03 = 100UL, + attribute04 = 100.001m, + attribute05 = 200.002f, + attribute06 = "text", + attribute07 = DateTime.MaxValue, + attribute08 = DateTimeOffset.MaxValue, + attribute09 = TimeSpan.MaxValue, + attribute10 = DayOfWeek.Friday + }, + relationships = new + { + single1 = new + { + data = new + { + type = "incomingResources", + id = "101" + } + }, + single2 = new + { + data = new + { + type = "incomingResources", + id = "102" + } + }, + single3 = new + { + data = new + { + type = "incomingResources", + id = "103" + } + }, + single4 = new + { + data = new + { + type = "incomingResources", + id = "104" + } + }, + single5 = new + { + data = new + { + type = "incomingResources", + id = "105" + } + }, + multi1 = new + { + data = new[] + { + new + { + type = "incomingResources", + id = "201" + } + } + }, + multi2 = new + { + data = new[] + { + new + { + type = "incomingResources", + id = "202" + } + } + }, + multi3 = new + { + data = new[] + { + new + { + type = "incomingResources", + id = "203" + } + } + }, + multi4 = new + { + data = new[] + { + new + { + type = "incomingResources", + id = "204" + } + } + }, + multi5 = new + { + data = new[] + { + new + { + type = "incomingResources", + id = "205" + } + } + } + } + } + }, + new + { + op = "update", + data = new + { + type = "incomingResources", + id = "1", + attributes = new + { + attribute01 = true, + attribute02 = 'A', + attribute03 = 100UL, + attribute04 = 100.001m, + attribute05 = 200.002f, + attribute06 = "text", + attribute07 = DateTime.MaxValue, + attribute08 = DateTimeOffset.MaxValue, + attribute09 = TimeSpan.MaxValue, + attribute10 = DayOfWeek.Friday + }, + relationships = new + { + single1 = new + { + data = new + { + type = "incomingResources", + id = "101" + } + }, + single2 = new + { + data = new + { + type = "incomingResources", + id = "102" + } + }, + single3 = new + { + data = new + { + type = "incomingResources", + id = "103" + } + }, + single4 = new + { + data = new + { + type = "incomingResources", + id = "104" + } + }, + single5 = new + { + data = new + { + type = "incomingResources", + id = "105" + } + }, + multi1 = new + { + data = new[] + { + new + { + type = "incomingResources", + id = "201" + } + } + }, + multi2 = new + { + data = new[] + { + new + { + type = "incomingResources", + id = "202" + } + } + }, + multi3 = new + { + data = new[] + { + new + { + type = "incomingResources", + id = "203" + } + } + }, + multi4 = new + { + data = new[] + { + new + { + type = "incomingResources", + id = "204" + } + } + }, + multi5 = new + { + data = new[] + { + new + { + type = "incomingResources", + id = "205" + } + } + } + } + } + }, + new + { + op = "remove", + @ref = new + { + type = "incomingResources", + lid = "a-1" + } + } + } + }).Replace("atomic__operations", "atomic:operations"); + + [Benchmark] + public object? DeserializeOperationsRequest() + { + var document = JsonSerializer.Deserialize(RequestBody, SerializerReadOptions)!; + return DocumentAdapter.Convert(document); + } + + protected override JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph) + { + return new JsonApiRequest + { + Kind = EndpointKind.AtomicOperations + }; + } +} diff --git a/benchmarks/Deserialization/ResourceDeserializationBenchmarks.cs b/benchmarks/Deserialization/ResourceDeserializationBenchmarks.cs new file mode 100644 index 0000000000..e503a329bb --- /dev/null +++ b/benchmarks/Deserialization/ResourceDeserializationBenchmarks.cs @@ -0,0 +1,149 @@ +using System.Text.Json; +using BenchmarkDotNet.Attributes; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Serialization.Objects; + +namespace Benchmarks.Deserialization; + +[MarkdownExporter] +[MemoryDiagnoser] +// ReSharper disable once ClassCanBeSealed.Global +public class ResourceDeserializationBenchmarks : DeserializationBenchmarkBase +{ + private static readonly string RequestBody = JsonSerializer.Serialize(new + { + data = new + { + type = "incomingResources", + attributes = new + { + attribute01 = true, + attribute02 = 'A', + attribute03 = 100UL, + attribute04 = 100.001m, + attribute05 = 200.002f, + attribute06 = "text", + attribute07 = DateTime.MaxValue, + attribute08 = DateTimeOffset.MaxValue, + attribute09 = TimeSpan.MaxValue, + attribute10 = DayOfWeek.Friday + }, + relationships = new + { + single1 = new + { + data = new + { + type = "incomingResources", + id = "101" + } + }, + single2 = new + { + data = new + { + type = "incomingResources", + id = "102" + } + }, + single3 = new + { + data = new + { + type = "incomingResources", + id = "103" + } + }, + single4 = new + { + data = new + { + type = "incomingResources", + id = "104" + } + }, + single5 = new + { + data = new + { + type = "incomingResources", + id = "105" + } + }, + multi1 = new + { + data = new[] + { + new + { + type = "incomingResources", + id = "201" + } + } + }, + multi2 = new + { + data = new[] + { + new + { + type = "incomingResources", + id = "202" + } + } + }, + multi3 = new + { + data = new[] + { + new + { + type = "incomingResources", + id = "203" + } + } + }, + multi4 = new + { + data = new[] + { + new + { + type = "incomingResources", + id = "204" + } + } + }, + multi5 = new + { + data = new[] + { + new + { + type = "incomingResources", + id = "205" + } + } + } + } + } + }); + + [Benchmark] + public object? DeserializeResourceRequest() + { + var document = JsonSerializer.Deserialize(RequestBody, SerializerReadOptions)!; + return DocumentAdapter.Convert(document); + } + + protected override JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph) + { + return new JsonApiRequest + { + Kind = EndpointKind.Primary, + PrimaryResourceType = resourceGraph.GetResourceType(), + WriteOperation = WriteOperationKind.CreateResource + }; + } +} diff --git a/benchmarks/LinkBuilder/LinkBuilderGetNamespaceFromPathBenchmarks.cs b/benchmarks/LinkBuilder/LinkBuilderGetNamespaceFromPathBenchmarks.cs deleted file mode 100644 index b3dbef6232..0000000000 --- a/benchmarks/LinkBuilder/LinkBuilderGetNamespaceFromPathBenchmarks.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; -using System.Text; -using BenchmarkDotNet.Attributes; - -namespace Benchmarks.LinkBuilder -{ - // ReSharper disable once ClassCanBeSealed.Global - [MarkdownExporter] - [SimpleJob(3, 10, 20)] - [MemoryDiagnoser] - public class LinkBuilderGetNamespaceFromPathBenchmarks - { - private const string RequestPath = "/api/some-really-long-namespace-path/resources/current/articles/?some"; - private const string ResourceName = "articles"; - private const char PathDelimiter = '/'; - - [Benchmark] - public void UsingStringSplit() - { - GetNamespaceFromPathUsingStringSplit(RequestPath, ResourceName); - } - - [Benchmark] - public void UsingReadOnlySpan() - { - GetNamespaceFromPathUsingReadOnlySpan(RequestPath, ResourceName); - } - - private static void GetNamespaceFromPathUsingStringSplit(string path, string resourceName) - { - var namespaceBuilder = new StringBuilder(path.Length); - string[] segments = path.Split('/'); - - for (int index = 1; index < segments.Length; index++) - { - if (segments[index] == resourceName) - { - break; - } - - namespaceBuilder.Append(PathDelimiter); - namespaceBuilder.Append(segments[index]); - } - - _ = namespaceBuilder.ToString(); - } - - private static void GetNamespaceFromPathUsingReadOnlySpan(string path, string resourceName) - { - ReadOnlySpan resourceNameSpan = resourceName.AsSpan(); - ReadOnlySpan pathSpan = path.AsSpan(); - - for (int index = 0; index < pathSpan.Length; index++) - { - if (pathSpan[index].Equals(PathDelimiter)) - { - if (pathSpan.Length > index + resourceNameSpan.Length) - { - ReadOnlySpan possiblePathSegment = pathSpan.Slice(index + 1, resourceNameSpan.Length); - - if (resourceNameSpan.SequenceEqual(possiblePathSegment)) - { - int lastCharacterIndex = index + 1 + resourceNameSpan.Length; - - bool isAtEnd = lastCharacterIndex == pathSpan.Length; - bool hasDelimiterAfterSegment = pathSpan.Length >= lastCharacterIndex + 1 && pathSpan[lastCharacterIndex].Equals(PathDelimiter); - - if (isAtEnd || hasDelimiterAfterSegment) - { - _ = pathSpan.Slice(0, index).ToString(); - } - } - } - } - } - } - } -} diff --git a/benchmarks/Program.cs b/benchmarks/Program.cs index 0d745a795d..04d5fa1eaa 100644 --- a/benchmarks/Program.cs +++ b/benchmarks/Program.cs @@ -1,23 +1,14 @@ using BenchmarkDotNet.Running; -using Benchmarks.LinkBuilder; -using Benchmarks.Query; +using Benchmarks.Deserialization; +using Benchmarks.QueryString; using Benchmarks.Serialization; -namespace Benchmarks -{ - internal static class Program - { - private static void Main(string[] args) - { - var switcher = new BenchmarkSwitcher(new[] - { - typeof(JsonApiDeserializerBenchmarks), - typeof(JsonApiSerializerBenchmarks), - typeof(QueryParserBenchmarks), - typeof(LinkBuilderGetNamespaceFromPathBenchmarks) - }); +var switcher = new BenchmarkSwitcher([ + typeof(ResourceDeserializationBenchmarks), + typeof(OperationsDeserializationBenchmarks), + typeof(ResourceSerializationBenchmarks), + typeof(OperationsSerializationBenchmarks), + typeof(QueryStringParserBenchmarks) +]); - switcher.Run(args); - } - } -} +switcher.Run(args); diff --git a/benchmarks/Query/QueryParserBenchmarks.cs b/benchmarks/Query/QueryParserBenchmarks.cs deleted file mode 100644 index 553d9fa7ba..0000000000 --- a/benchmarks/Query/QueryParserBenchmarks.cs +++ /dev/null @@ -1,127 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.Design; -using BenchmarkDotNet.Attributes; -using JsonApiDotNetCore; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -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.Query -{ - // ReSharper disable once ClassCanBeSealed.Global - [MarkdownExporter] - [SimpleJob(3, 10, 20)] - [MemoryDiagnoser] - public class QueryParserBenchmarks - { - private readonly DependencyFactory _dependencyFactory = new DependencyFactory(); - private readonly FakeRequestQueryStringAccessor _queryStringAccessor = new FakeRequestQueryStringAccessor(); - private readonly QueryStringReader _queryStringReaderForSort; - private readonly QueryStringReader _queryStringReaderForAll; - - public QueryParserBenchmarks() - { - IJsonApiOptions options = new JsonApiOptions - { - EnableLegacyFilterNotation = true - }; - - IResourceGraph resourceGraph = _dependencyFactory.CreateResourceGraph(options); - - var request = new JsonApiRequest - { - PrimaryResource = resourceGraph.GetResourceContext(typeof(BenchmarkResource)), - IsCollection = true - }; - - _queryStringReaderForSort = CreateQueryParameterDiscoveryForSort(resourceGraph, request, options, _queryStringAccessor); - _queryStringReaderForAll = CreateQueryParameterDiscoveryForAll(resourceGraph, request, options, _queryStringAccessor); - } - - private static QueryStringReader CreateQueryParameterDiscoveryForSort(IResourceGraph resourceGraph, JsonApiRequest request, IJsonApiOptions options, - FakeRequestQueryStringAccessor queryStringAccessor) - { - var sortReader = new SortQueryStringParameterReader(request, resourceGraph); - - IEnumerable readers = sortReader.AsEnumerable(); - - return new QueryStringReader(options, queryStringAccessor, readers, NullLoggerFactory.Instance); - } - - private static QueryStringReader CreateQueryParameterDiscoveryForAll(IResourceGraph resourceGraph, JsonApiRequest request, IJsonApiOptions options, - FakeRequestQueryStringAccessor queryStringAccessor) - { - var resourceFactory = new ResourceFactory(new ServiceContainer()); - - 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 defaultsReader = new DefaultsQueryStringParameterReader(options); - var nullsReader = new NullsQueryStringParameterReader(options); - - IQueryStringParameterReader[] readers = ArrayFactory.Create(includeReader, filterReader, sortReader, - sparseFieldSetReader, paginationReader, defaultsReader, nullsReader); - - return new QueryStringReader(options, queryStringAccessor, readers, NullLoggerFactory.Instance); - } - - [Benchmark] - public void AscendingSort() - { - string queryString = $"?sort={BenchmarkResourcePublicNames.NameAttr}"; - - _queryStringAccessor.SetQueryString(queryString); - _queryStringReaderForSort.ReadAll(null); - } - - [Benchmark] - public void DescendingSort() - { - string queryString = $"?sort=-{BenchmarkResourcePublicNames.NameAttr}"; - - _queryStringAccessor.SetQueryString(queryString); - _queryStringReaderForSort.ReadAll(null); - } - - [Benchmark] - public void ComplexQuery() - { - Run(100, () => - { - const string resourceName = BenchmarkResourcePublicNames.Type; - const string attrName = BenchmarkResourcePublicNames.NameAttr; - - string queryString = $"?filter[{attrName}]=abc,eq:abc&sort=-{attrName}&include=child&page[size]=1&fields[{resourceName}]={attrName}"; - - _queryStringAccessor.SetQueryString(queryString); - _queryStringReaderForAll.ReadAll(null); - }); - } - - private void Run(int iterations, Action action) - { - for (int index = 0; index < iterations; index++) - { - action(); - } - } - - private sealed class FakeRequestQueryStringAccessor : IRequestQueryStringAccessor - { - public IQueryCollection Query { get; private set; } - - public void SetQueryString(string queryString) - { - Query = new QueryCollection(QueryHelpers.ParseQuery(queryString)); - } - } - } -} diff --git a/benchmarks/QueryString/QueryStringParserBenchmarks.cs b/benchmarks/QueryString/QueryStringParserBenchmarks.cs new file mode 100644 index 0000000000..5e5a65ed9f --- /dev/null +++ b/benchmarks/QueryString/QueryStringParserBenchmarks.cs @@ -0,0 +1,112 @@ +using System.ComponentModel.Design; +using BenchmarkDotNet.Attributes; +using Benchmarks.Tools; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.Resources; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Benchmarks.QueryString; + +// ReSharper disable once ClassCanBeSealed.Global +[MarkdownExporter] +[SimpleJob(3, 10, 20)] +[MemoryDiagnoser] +public class QueryStringParserBenchmarks : IDisposable +{ + private readonly ServiceContainer _serviceProvider = new(); + private readonly FakeRequestQueryStringAccessor _queryStringAccessor = new(); + private readonly QueryStringReader _queryStringReader; + + public QueryStringParserBenchmarks() + { + IJsonApiOptions options = new JsonApiOptions + { + EnableLegacyFilterNotation = true + }; + + IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add("alt-resource-name").Build(); + + var request = new JsonApiRequest + { + PrimaryResourceType = resourceGraph.GetResourceType(), + IsCollection = true + }; + + var resourceFactory = new ResourceFactory(_serviceProvider); + + var includeParser = new IncludeParser(options); + var includeReader = new IncludeQueryStringParameterReader(includeParser, request, resourceGraph); + + 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); + } + + [Benchmark] + public void AscendingSort() + { + const string queryString = "?sort=alt-attr-name"; + + _queryStringAccessor.SetQueryString(queryString); + _queryStringReader.ReadAll(null); + } + + [Benchmark] + public void DescendingSort() + { + const string queryString = "?sort=-alt-attr-name"; + + _queryStringAccessor.SetQueryString(queryString); + _queryStringReader.ReadAll(null); + } + + [Benchmark] + public void ComplexQuery() + { + 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); + } + + 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(); + } + } +} diff --git a/benchmarks/QueryString/QueryableResource.cs b/benchmarks/QueryString/QueryableResource.cs new file mode 100644 index 0000000000..7c26474ae4 --- /dev/null +++ b/benchmarks/QueryString/QueryableResource.cs @@ -0,0 +1,15 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace Benchmarks.QueryString; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class QueryableResource : Identifiable +{ + [Attr(PublicName = "alt-attr-name")] + public string? Name { get; set; } + + [HasOne] + public QueryableResource? Child { get; set; } +} diff --git a/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs b/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs deleted file mode 100644 index f4dbd5a829..0000000000 --- a/benchmarks/Serialization/JsonApiDeserializerBenchmarks.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.Design; -using BenchmarkDotNet.Attributes; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.AspNetCore.Http; -using Newtonsoft.Json; - -namespace Benchmarks.Serialization -{ - // ReSharper disable once ClassCanBeSealed.Global - [MarkdownExporter] - public class JsonApiDeserializerBenchmarks - { - private static readonly string Content = JsonConvert.SerializeObject(new Document - { - Data = new ResourceObject - { - Type = BenchmarkResourcePublicNames.Type, - Id = "1", - Attributes = new Dictionary - { - ["name"] = Guid.NewGuid().ToString() - } - } - }); - - private readonly DependencyFactory _dependencyFactory = new DependencyFactory(); - private readonly IJsonApiDeserializer _jsonApiDeserializer; - - public JsonApiDeserializerBenchmarks() - { - var options = new JsonApiOptions(); - IResourceGraph resourceGraph = _dependencyFactory.CreateResourceGraph(options); - - var serviceContainer = new ServiceContainer(); - serviceContainer.AddService(typeof(IResourceDefinitionAccessor), new ResourceDefinitionAccessor(resourceGraph, serviceContainer)); - serviceContainer.AddService(typeof(IResourceDefinition), new JsonApiResourceDefinition(resourceGraph)); - - var targetedFields = new TargetedFields(); - var request = new JsonApiRequest(); - var resourceFactory = new ResourceFactory(serviceContainer); - var httpContextAccessor = new HttpContextAccessor(); - - _jsonApiDeserializer = new RequestDeserializer(resourceGraph, resourceFactory, targetedFields, httpContextAccessor, request, options); - } - - [Benchmark] - public object DeserializeSimpleObject() - { - return _jsonApiDeserializer.Deserialize(Content); - } - } -} diff --git a/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs b/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs deleted file mode 100644 index 3d9cefc600..0000000000 --- a/benchmarks/Serialization/JsonApiSerializerBenchmarks.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System; -using BenchmarkDotNet.Attributes; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.QueryStrings.Internal; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Serialization.Building; -using Moq; - -namespace Benchmarks.Serialization -{ - // ReSharper disable once ClassCanBeSealed.Global - [MarkdownExporter] - public class JsonApiSerializerBenchmarks - { - private static readonly BenchmarkResource Content = new BenchmarkResource - { - Id = 123, - Name = Guid.NewGuid().ToString() - }; - - private readonly DependencyFactory _dependencyFactory = new DependencyFactory(); - private readonly IJsonApiSerializer _jsonApiSerializer; - - public JsonApiSerializerBenchmarks() - { - var options = new JsonApiOptions(); - IResourceGraph resourceGraph = _dependencyFactory.CreateResourceGraph(options); - IFieldsToSerialize fieldsToSerialize = CreateFieldsToSerialize(resourceGraph); - - IMetaBuilder metaBuilder = new Mock().Object; - ILinkBuilder linkBuilder = new Mock().Object; - IIncludedResourceObjectBuilder includeBuilder = new Mock().Object; - - var resourceObjectBuilder = new ResourceObjectBuilder(resourceGraph, new ResourceObjectBuilderSettings()); - - IResourceDefinitionAccessor resourceDefinitionAccessor = new Mock().Object; - - _jsonApiSerializer = new ResponseSerializer(metaBuilder, linkBuilder, includeBuilder, fieldsToSerialize, resourceObjectBuilder, - resourceDefinitionAccessor, options); - } - - private static FieldsToSerialize CreateFieldsToSerialize(IResourceGraph resourceGraph) - { - var request = new JsonApiRequest(); - - var constraintProviders = new IQueryConstraintProvider[] - { - new SparseFieldSetQueryStringParameterReader(request, resourceGraph) - }; - - IResourceDefinitionAccessor accessor = new Mock().Object; - - return new FieldsToSerialize(resourceGraph, constraintProviders, accessor, request); - } - - [Benchmark] - public object SerializeSimpleObject() - { - return _jsonApiSerializer.Serialize(Content); - } - } -} diff --git a/benchmarks/Serialization/OperationsSerializationBenchmarks.cs b/benchmarks/Serialization/OperationsSerializationBenchmarks.cs new file mode 100644 index 0000000000..8c4a00b6da --- /dev/null +++ b/benchmarks/Serialization/OperationsSerializationBenchmarks.cs @@ -0,0 +1,135 @@ +using System.Text.Json; +using BenchmarkDotNet.Attributes; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +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 List _responseOperations; + + public OperationsSerializationBenchmarks() + { + // ReSharper disable once VirtualMemberCallInConstructor + JsonApiRequest request = CreateJsonApiRequest(ResourceGraph); + + _responseOperations = CreateResponseOperations(request); + } + + private static List CreateResponseOperations(IJsonApiRequest request) + { + var resource1 = new OutgoingResource + { + Id = 1, + Attribute01 = true, + Attribute02 = 'A', + Attribute03 = 100UL, + Attribute04 = 100.001m, + Attribute05 = 100.002f, + Attribute06 = "text1", + Attribute07 = new DateTime(2001, 1, 1), + Attribute08 = new DateTimeOffset(2001, 1, 1, 0, 0, 0, TimeSpan.FromHours(1)), + Attribute09 = new TimeSpan(1, 0, 0), + Attribute10 = DayOfWeek.Sunday + }; + + var resource2 = new OutgoingResource + { + Id = 2, + Attribute01 = false, + Attribute02 = 'B', + Attribute03 = 200UL, + Attribute04 = 200.001m, + Attribute05 = 200.002f, + Attribute06 = "text2", + Attribute07 = new DateTime(2002, 2, 2), + Attribute08 = new DateTimeOffset(2002, 2, 2, 0, 0, 0, TimeSpan.FromHours(2)), + Attribute09 = new TimeSpan(2, 0, 0), + Attribute10 = DayOfWeek.Monday + }; + + var resource3 = new OutgoingResource + { + Id = 3, + Attribute01 = true, + Attribute02 = 'C', + Attribute03 = 300UL, + Attribute04 = 300.001m, + Attribute05 = 300.002f, + Attribute06 = "text3", + Attribute07 = new DateTime(2003, 3, 3), + Attribute08 = new DateTimeOffset(2003, 3, 3, 0, 0, 0, TimeSpan.FromHours(3)), + Attribute09 = new TimeSpan(3, 0, 0), + Attribute10 = DayOfWeek.Tuesday + }; + + var resource4 = new OutgoingResource + { + Id = 4, + Attribute01 = false, + Attribute02 = 'D', + Attribute03 = 400UL, + Attribute04 = 400.001m, + Attribute05 = 400.002f, + Attribute06 = "text4", + Attribute07 = new DateTime(2004, 4, 4), + Attribute08 = new DateTimeOffset(2004, 4, 4, 0, 0, 0, TimeSpan.FromHours(4)), + Attribute09 = new TimeSpan(4, 0, 0), + Attribute10 = DayOfWeek.Wednesday + }; + + var resource5 = new OutgoingResource + { + Id = 5, + Attribute01 = true, + Attribute02 = 'E', + Attribute03 = 500UL, + Attribute04 = 500.001m, + Attribute05 = 500.002f, + Attribute06 = "text5", + Attribute07 = new DateTime(2005, 5, 5), + Attribute08 = new DateTimeOffset(2005, 5, 5, 0, 0, 0, TimeSpan.FromHours(5)), + Attribute09 = new TimeSpan(5, 0, 0), + Attribute10 = DayOfWeek.Thursday + }; + + var targetedFields = new TargetedFields(); + + 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] + public string SerializeOperationsResponse() + { + Document responseDocument = ResponseModelAdapter.Convert(_responseOperations); + return JsonSerializer.Serialize(responseDocument, SerializerWriteOptions); + } + + protected override JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph) + { + return new JsonApiRequest + { + Kind = EndpointKind.AtomicOperations, + PrimaryResourceType = resourceGraph.GetResourceType() + }; + } + + protected override IEvaluatedIncludeCache CreateEvaluatedIncludeCache(IResourceGraph resourceGraph) + { + return new EvaluatedIncludeCache(Array.Empty()); + } +} diff --git a/benchmarks/Serialization/ResourceSerializationBenchmarks.cs b/benchmarks/Serialization/ResourceSerializationBenchmarks.cs new file mode 100644 index 0000000000..6f979e86b9 --- /dev/null +++ b/benchmarks/Serialization/ResourceSerializationBenchmarks.cs @@ -0,0 +1,153 @@ +using System.Collections.Immutable; +using System.Text.Json; +using BenchmarkDotNet.Attributes; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +namespace Benchmarks.Serialization; + +[MarkdownExporter] +[MemoryDiagnoser] +// ReSharper disable once ClassCanBeSealed.Global +public class ResourceSerializationBenchmarks : SerializationBenchmarkBase +{ + private static readonly OutgoingResource ResponseResource = CreateResponseResource(); + + private static OutgoingResource CreateResponseResource() + { + var resource1 = new OutgoingResource + { + Id = 1, + Attribute01 = true, + Attribute02 = 'A', + Attribute03 = 100UL, + Attribute04 = 100.001m, + Attribute05 = 100.002f, + Attribute06 = "text1", + Attribute07 = new DateTime(2001, 1, 1), + Attribute08 = new DateTimeOffset(2001, 1, 1, 0, 0, 0, TimeSpan.FromHours(1)), + Attribute09 = new TimeSpan(1, 0, 0), + Attribute10 = DayOfWeek.Sunday + }; + + var resource2 = new OutgoingResource + { + Id = 2, + Attribute01 = false, + Attribute02 = 'B', + Attribute03 = 200UL, + Attribute04 = 200.001m, + Attribute05 = 200.002f, + Attribute06 = "text2", + Attribute07 = new DateTime(2002, 2, 2), + Attribute08 = new DateTimeOffset(2002, 2, 2, 0, 0, 0, TimeSpan.FromHours(2)), + Attribute09 = new TimeSpan(2, 0, 0), + Attribute10 = DayOfWeek.Monday + }; + + var resource3 = new OutgoingResource + { + Id = 3, + Attribute01 = true, + Attribute02 = 'C', + Attribute03 = 300UL, + Attribute04 = 300.001m, + Attribute05 = 300.002f, + Attribute06 = "text3", + Attribute07 = new DateTime(2003, 3, 3), + Attribute08 = new DateTimeOffset(2003, 3, 3, 0, 0, 0, TimeSpan.FromHours(3)), + Attribute09 = new TimeSpan(3, 0, 0), + Attribute10 = DayOfWeek.Tuesday + }; + + var resource4 = new OutgoingResource + { + Id = 4, + Attribute01 = false, + Attribute02 = 'D', + Attribute03 = 400UL, + Attribute04 = 400.001m, + Attribute05 = 400.002f, + Attribute06 = "text4", + Attribute07 = new DateTime(2004, 4, 4), + Attribute08 = new DateTimeOffset(2004, 4, 4, 0, 0, 0, TimeSpan.FromHours(4)), + Attribute09 = new TimeSpan(4, 0, 0), + Attribute10 = DayOfWeek.Wednesday + }; + + var resource5 = new OutgoingResource + { + Id = 5, + Attribute01 = true, + Attribute02 = 'E', + Attribute03 = 500UL, + Attribute04 = 500.001m, + Attribute05 = 500.002f, + Attribute06 = "text5", + Attribute07 = new DateTime(2005, 5, 5), + Attribute08 = new DateTimeOffset(2005, 5, 5, 0, 0, 0, TimeSpan.FromHours(5)), + Attribute09 = new TimeSpan(5, 0, 0), + Attribute10 = DayOfWeek.Thursday + }; + + resource1.Single2 = resource2; + resource2.Single3 = resource3; + resource3.Multi4 = ToHashSet(resource4); + resource4.Multi5 = ToHashSet(resource5); + + return resource1; + } + + private static HashSet ToHashSet(T element) + { + return [element]; + } + + [Benchmark] + public string SerializeResourceResponse() + { + Document responseDocument = ResponseModelAdapter.Convert(ResponseResource); + return JsonSerializer.Serialize(responseDocument, SerializerWriteOptions); + } + + protected override JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph) + { + return new JsonApiRequest + { + Kind = EndpointKind.Primary, + PrimaryResourceType = resourceGraph.GetResourceType() + }; + } + + protected override IEvaluatedIncludeCache CreateEvaluatedIncludeCache(IResourceGraph resourceGraph) + { + ResourceType resourceType = resourceGraph.GetResourceType(); + + 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 + { + new(single2, new HashSet + { + new(single3, new HashSet + { + new(multi4, new HashSet + { + new(multi5) + }.ToImmutableHashSet()) + }.ToImmutableHashSet()) + }.ToImmutableHashSet()) + }.ToImmutableHashSet()); + + var cache = new EvaluatedIncludeCache(Array.Empty()); + cache.Set(include); + return cache; + } +} diff --git a/benchmarks/Serialization/SerializationBenchmarkBase.cs b/benchmarks/Serialization/SerializationBenchmarkBase.cs new file mode 100644 index 0000000000..c8451835cc --- /dev/null +++ b/benchmarks/Serialization/SerializationBenchmarkBase.cs @@ -0,0 +1,120 @@ +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.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Response; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Benchmarks.Serialization; + +public abstract class SerializationBenchmarkBase +{ + protected JsonSerializerOptions SerializerWriteOptions { get; } + protected IResponseModelAdapter ResponseModelAdapter { get; } + protected IResourceGraph ResourceGraph { get; } + + protected SerializationBenchmarkBase() + { + var options = new JsonApiOptions + { + SerializerOptions = + { + Converters = + { + new JsonStringEnumConverter() + } + } + }; + + ResourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add().Build(); + SerializerWriteOptions = ((IJsonApiOptions)options).SerializerWriteOptions; + + // ReSharper disable VirtualMemberCallInConstructor + JsonApiRequest request = CreateJsonApiRequest(ResourceGraph); + IEvaluatedIncludeCache evaluatedIncludeCache = CreateEvaluatedIncludeCache(ResourceGraph); + // ReSharper restore VirtualMemberCallInConstructor + + var linkBuilder = new FakeLinkBuilder(); + var metaBuilder = new NoMetaBuilder(); + IQueryConstraintProvider[] constraintProviders = []; + var resourceDefinitionAccessor = new NeverResourceDefinitionAccessor(); + var sparseFieldSetCache = new SparseFieldSetCache(constraintProviders, resourceDefinitionAccessor); + var requestQueryStringAccessor = new FakeRequestQueryStringAccessor(); + + ResponseModelAdapter = new ResponseModelAdapter(request, options, linkBuilder, metaBuilder, resourceDefinitionAccessor, evaluatedIncludeCache, + sparseFieldSetCache, requestQueryStringAccessor); + } + + protected abstract JsonApiRequest CreateJsonApiRequest(IResourceGraph resourceGraph); + + protected abstract IEvaluatedIncludeCache CreateEvaluatedIncludeCache(IResourceGraph resourceGraph); + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class OutgoingResource : Identifiable + { + [Attr] + public bool Attribute01 { get; set; } + + [Attr] + public char Attribute02 { get; set; } + + [Attr] + public ulong? Attribute03 { get; set; } + + [Attr] + public decimal Attribute04 { get; set; } + + [Attr] + public float? Attribute05 { get; set; } + + [Attr] + public string Attribute06 { get; set; } = null!; + + [Attr] + public DateTime? Attribute07 { get; set; } + + [Attr] + public DateTimeOffset? Attribute08 { get; set; } + + [Attr] + public TimeSpan? Attribute09 { get; set; } + + [Attr] + public DayOfWeek Attribute10 { get; set; } + + [HasOne] + public OutgoingResource Single1 { get; set; } = null!; + + [HasOne] + public OutgoingResource Single2 { get; set; } = null!; + + [HasOne] + public OutgoingResource Single3 { get; set; } = null!; + + [HasOne] + public OutgoingResource Single4 { get; set; } = null!; + + [HasOne] + public OutgoingResource Single5 { get; set; } = null!; + + [HasMany] + public ISet Multi1 { get; set; } = null!; + + [HasMany] + public ISet Multi2 { get; set; } = null!; + + [HasMany] + public ISet Multi3 { get; set; } = null!; + + [HasMany] + public ISet Multi4 { get; set; } = null!; + + [HasMany] + public ISet Multi5 { get; set; } = null!; + } +} diff --git a/benchmarks/SubResource.cs b/benchmarks/SubResource.cs deleted file mode 100644 index 73536a87ae..0000000000 --- a/benchmarks/SubResource.cs +++ /dev/null @@ -1,13 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace Benchmarks -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class SubResource : Identifiable - { - [Attr] - public string Value { get; set; } - } -} 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 605ebff705..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 build -c Release +dotnet tool restore +VerifySuccessExitCode + +dotnet restore +VerifySuccessExitCode -if ($LASTEXITCODE -ne 0) { - throw "Build failed with exit code $LASTEXITCODE" -} +if ($revision) { + $headCommitHash = git rev-parse HEAD + VerifySuccessExitCode -dotnet regitlint -s JsonApiDotNetCore.sln --print-command --jb --profile --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/codecov.yml b/codecov.yml index 551e7d4c54..32a518442e 100644 --- a/codecov.yml +++ b/codecov.yml @@ -8,3 +8,6 @@ coverage: patch: default: informational: true + +github_checks: + annotations: false 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 c8e4a69a3d..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-1.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 6acc17ce7a..b073247dd3 100644 --- a/docs/docfx.json +++ b/docs/docfx.json @@ -1,53 +1,55 @@ { - "metadata": [ + "$schema": "https://raw.githubusercontent.com/dotnet/docfx/main/schemas/docfx.schema.json", + "metadata": [ + { + "properties": { + "ProduceReferenceAssembly": "true" + }, + "src": [ { - "src": [ - { - "files": [ "**/JsonApiDotNetCore.csproj" ], - "src": "../" - } - ], - "dest": "api", - "disableGitFeatures": false, - "properties": { - "targetFramework": "netcoreapp3.1" - } + "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 11a5e53471..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 d6d530d750..b09e389c91 100644 --- a/docs/getting-started/step-by-step.md +++ b/docs/getting-started/step-by-step.md @@ -1,142 +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 web app -- Install -- Define models -- Define the DbContext -- Define controllers -- Add Middleware and Services -- Seed the database -- Start the app - -This page will walk you through the **simplest** use case. More detailed examples can be found in the detailed usage subsections. - -### Create A New Web App - -``` -mkdir MyApp -cd MyApp -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# -public class Person : Identifiable -{ - [Attr] - public string Name { get; set; } -} -``` - -### Define DbContext - -Nothing special here, just an ordinary `DbContext` - -``` -public class AppDbContext : DbContext -{ - public AppDbContext(DbContextOptions options) - : base(options) - { - } - - public DbSet People { get; set; } -} -``` - -### Define Controllers - -You need to create controllers that inherit from `JsonApiController` or `JsonApiController` -where `TResource` is the model that inherits from `Identifiable` - -```c# -public class PeopleController : JsonApiController -{ - public PeopleController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } -} -``` - -### Middleware and Services - -Finally, add the services by adding the following to your Startup.ConfigureServices: - -```c# -// This method gets called by the runtime. Use this method to add services to the container. -public void ConfigureServices(IServiceCollection services) -{ - // Add the Entity Framework Core DbContext like you normally would - services.AddDbContext(options => - { - // Use whatever provider you want, this is just an example - options.UseNpgsql(GetDbConnectionString()); - }); - - // Add JsonApiDotNetCore - services.AddJsonApi(); -} -``` - -Add the middleware to the Startup.Configure method. - -```c# -// This method gets called by the runtime. Use this method to configure the HTTP request pipeline. -public void Configure(IApplicationBuilder app) -{ - app.UseRouting(); - app.UseJsonApi(); - app.UseEndpoints(endpoints => endpoints.MapControllers()); -} -``` - -### Seeding the Database - -One way to seed the database is in your Configure method: - -```c# -public void Configure(IApplicationBuilder app, AppDbContext context) -{ - context.Database.EnsureCreated(); - - if (!context.People.Any()) - { - context.People.Add(new Person - { - Name = "John Doe" - }); - - context.SaveChanges(); - } - - app.UseRouting(); - app.UseJsonApi(); - app.UseEndpoints(endpoints => endpoints.MapControllers()); -} -``` - -### Start the App - -``` -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 661819f3f6..a8530ec89a 100644 --- a/docs/home/index.html +++ b/docs/home/index.html @@ -1,205 +1,245 @@ - - - - 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

-
-public class Article : Identifiable
+          
+ +
+
+
+

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>
 {
     [Attr]
-    [Required, MaxLength(30)]
-    public string Title { get; set; }
+    [MaxLength(30)]
+    public string Title { get; set; } = null!;
 
     [Attr(Capabilities = AttrCapabilities.AllowFilter)]
-    public string Summary { get; set; }
+    public string? Summary { get; set; }
 
     [Attr(PublicName = "websiteUrl")]
-    public string Url { get; set; }
+    public string? Url { get; set; }
+
+    [Attr]
+    [Required]
+    public int? WordCount { get; set; }
 
     [Attr(Capabilities = AttrCapabilities.AllowView)]
     public DateTimeOffset LastModifiedAt { get; set; }
 
     [HasOne]
-    public Person Author { get; set; }
+    public Person Author { get; set; } = null!;
 
-    [HasMany]
-    public ICollection<Revision> Revisions { get; set; }  
+    [HasOne]
+    public Person? Reviewer { get; set; }
 
-    [HasManyThrough(nameof(ArticleTags))]
-    [NotMapped]
-    public ICollection<Tag> Tags { get; set; }
-    public ICollection<ArticleTag> ArticleTags { get; set; }
+    [HasMany]
+    public ICollection<Tag> Tags { get; set; } = new HashSet<Tag>();
 }
-                     
-
-
+
-
-
-
-
-

Request

-
-
-GET /articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields=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=title,summary&include=author",
-    "first": "/articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields=title,summary&include=author",
-    "last": "/articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields=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": [
     {
@@ -248,31 +288,54 @@ 

Response

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

Sponsors

+
+
+
+
+
+ JetBrains Logo +
-
-
-
-
- - - - - - - - - - + + + +
+ +
+ + + + + + + + + + diff --git a/docs/internals/queries.md b/docs/internals/queries.md index 9268ff24dc..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 Core]--> QueryString --[JADNC:QueryStringParameterReader]--> QueryExpression[] --[JADNC:ResourceService]--> QueryLayer --[JADNC:Repository]--> IQueryable --[EF 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: @@ -18,29 +19,29 @@ Processing a request involves the following steps: - The readers also implement `IQueryConstraintProvider`, which exposes expressions through `ExpressionInScope` objects. - `QueryLayerComposer` (used from `JsonApiResourceService`) collects all query constraints. - It combines them with default options and `IResourceDefinition` overrides and composes a tree of `QueryLayer` objects. - - It lifts the tree for nested endpoints like /blogs/1/articles and rewrites includes. + - It lifts the tree for secondary endpoints like /blogs/1/articles and rewrites includes. - `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 EF 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 080115161c..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 4173f27c4f..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 `./generate-examples.ps1` -4. Verify the results by running `docfx --serve` +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/errors.md b/docs/usage/errors.md index 96722739b4..955612dc67 100644 --- a/docs/usage/errors.md +++ b/docs/usage/errors.md @@ -1,26 +1,26 @@ # Errors -Errors returned will contain only the properties that are set on the `Error` class. Custom fields can be added through `Error.Meta`. -You can create a custom error by throwing a `JsonApiException` (which accepts an `Error` instance), or returning an `Error` instance from an `ActionResult` in a controller. -Please keep in mind that JSON:API requires Title to be a generic message, while Detail should contain information about the specific problem occurence. +Errors returned will contain only the properties that are set on the `ErrorObject` class. Custom fields can be added through `ErrorObject.Meta`. +You can create a custom error by throwing a `JsonApiException` (which accepts an `ErrorObject` instance), or returning an `ErrorObject` instance from an `ActionResult` in a controller. +Please keep in mind that JSON:API requires `Title` to be a generic message, while `Detail` should contain information about the specific problem occurence. From a controller method: ```c# -return Conflict(new Error(HttpStatusCode.Conflict) +return Conflict(new ErrorObject(HttpStatusCode.Conflict) { Title = "Target resource was modified by another user.", - Detail = $"User {userName} changed the {resourceField} field on the {resourceName} resource." + Detail = $"User {userName} changed the {resourceField} field on {resourceName} resource." }); ``` From other code: ```c# -throw new JsonApiException(new Error(HttpStatusCode.Conflict) +throw new JsonApiException(new ErrorObject(HttpStatusCode.Conflict) { Title = "Target resource was modified by another user.", - Detail = $"User {userName} changed the {resourceField} field on the {resourceName} resource." + Detail = $"User {userName} changed the {resourceField} field on {resourceName} resource." }); ``` @@ -69,26 +69,25 @@ public class CustomExceptionHandler : ExceptionHandler return base.GetLogMessage(exception); } - protected override ErrorDocument CreateErrorDocument(Exception exception) + protected override IReadOnlyList CreateErrorResponse(Exception exception) { if (exception is ProductOutOfStockException productOutOfStock) { - return new ErrorDocument(new Error(HttpStatusCode.Conflict) + return new[] { - Title = "Product is temporarily available.", - Detail = $"Product {productOutOfStock.ProductId} cannot be ordered at the moment." - }); + new ErrorObject(HttpStatusCode.Conflict) + { + Title = "Product is temporarily available.", + Detail = $"Product {productOutOfStock.ProductId} " + + "cannot be ordered at the moment." + } + }; } - return base.CreateErrorDocument(exception); + return base.CreateErrorResponse(exception); } } -public class Startup -{ - public void ConfigureServices(IServiceCollection services) - { - services.AddScoped(); - } -} +// Program.cs +builder.Services.AddScoped(); ``` diff --git a/docs/usage/extensibility/controllers.md b/docs/usage/extensibility/controllers.md index 1f9de7a473..254b305ed9 100644 --- a/docs/usage/extensibility/controllers.md +++ b/docs/usage/extensibility/controllers.md @@ -1,112 +1,139 @@ # Controllers -You need to create controllers that inherit from `JsonApiController` +To expose API endpoints, ASP.NET controllers need to be defined. + +## Auto-generated controllers + +_since v5_ + +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# -public class ArticlesController : JsonApiController
+[Resource] // Generates ArticlesController.g.cs +public class Article : Identifiable { - public ArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService
resourceService) - : base(options, loggerFactory, resourceService) - { - } + // ... } ``` -## Non-Integer Type Keys +> [!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. -If your model is using a type other than `int` for the primary key, you must explicitly declare it in the controller/service/repository definitions. +### Resource Access Control + +It is often desirable to limit which endpoints are exposed on your controller. +A subset can be specified too: ```c# -public class ArticlesController : JsonApiController -//---------------------------------------------------------- ^^^^ +[Resource(GenerateControllerEndpoints = + JsonApiEndpoints.GetCollection | JsonApiEndpoints.GetSingle)] +public class Article : Identifiable { - public ArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) - //----------------------- ^^^^ - : base(options, loggerFactory, resourceService) - { - } + // ... } ``` -## Resource Access Control - -It is often desirable to limit what methods are exposed on your controller. The first way you can do this, is to simply inherit from `BaseJsonApiController` and explicitly declare what methods are available. +Instead of passing a set of endpoints, you can use `JsonApiEndpoints.Query` to generate all read-only endpoints or `JsonApiEndpoints.Command` for all write-only endpoints. -In this example, if a client attempts to do anything other than GET a resource, an HTTP 404 Not Found response will be returned since no other methods are exposed. +When an endpoint is blocked, an HTTP 403 Forbidden response is returned. -This approach is ok, but introduces some boilerplate that can easily be avoided. +```http +DELETE http://localhost:14140/articles/1 HTTP/1.1 +``` -```c# -public class ArticlesController : BaseJsonApiController
+```json { - public ArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService
resourceService) - : base(options, loggerFactory, resourceService) + "links": { + "self": "/articles/1" + }, + "errors": [ { + "id": "dde7f219-2274-4473-97ef-baac3e7c1487", + "status": "403", + "title": "The requested endpoint is not accessible.", + "detail": "Endpoint '/articles/1' is not accessible for DELETE requests." } + ] +} +``` + +### Augmenting controllers + +Auto-generated controllers can easily be augmented because they are partial classes. For example: - [HttpGet] - public override async Task GetAsync(CancellationToken cancellationToken) +```c# +[DisableRoutingConvention] +[Route("some/custom/route")] +[DisableQueryString(JsonApiQueryStringParameters.Include)] +partial class ArticlesController +{ + [HttpPost] + public IActionResult Upload() { - return await base.GetAsync(cancellationToken); + // ... } +} +``` + +If you need to inject extra dependencies, tell the IoC container with `[ActivatorUtilitiesConstructor]` to prefer your constructor: + +```c# +partial class ArticlesController +{ + private IAuthenticationService _authService; - [HttpGet("{id}")] - public override async Task GetAsync(int id, - CancellationToken cancellationToken) + [ActivatorUtilitiesConstructor] + public ArticlesController(IAuthenticationService authService, IJsonApiOptions options, + IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { - return await base.GetAsync(id, cancellationToken); + _authService = authService; } } ``` -## Using ActionFilterAttributes - -The next option is to use the ActionFilter attributes that ship with the library. The available attributes are: +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)]`. -- `NoHttpPost`: disallow POST requests -- `NoHttpPatch`: disallow PATCH requests -- `NoHttpDelete`: disallow DELETE requests -- `HttpReadOnly`: all of the above +## Explicit controllers -Not only does this reduce boilerplate, but it also provides a more meaningful HTTP response code. -An attempt to use one of the blacklisted methods will result in a HTTP 405 Method Not Allowed response. +To define your own controller class, inherit from `JsonApiController`. For example: ```c# -[HttpReadOnly] -public class ArticlesController : BaseJsonApiController
+public class ArticlesController : JsonApiController { - public ArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService
resourceService) - : base(options, loggerFactory, resourceService) + public ArticlesController(IJsonApiOptions options, IResourceGraph resourceGraph, + ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } ``` -## Implicit Access By Service Injection +If you want to setup routes yourself, you can instead inherit from `BaseJsonApiController` and override its methods with your own `[HttpGet]`, `[HttpHead]`, `[HttpPost]`, `[HttpPatch]` and `[HttpDelete]` attributes added on them. Don't forget to add `[FromBody]` on parameters where needed. + +### Resource Access Control -Finally, you can control the allowed methods by supplying only the available service implementations. In some cases, resources may be an aggregation of entities or a view on top of the underlying entities. In these cases, there may not be a writable IResourceService implementation, so simply inject the implementation that is available. +It is often desirable to limit which routes are exposed on your controller. -As with the ActionFilter attributes, if a service implementation is not available to service a request, HTTP 405 Method Not Allowed will be returned. +To provide read-only access, inherit from `JsonApiQueryController` instead, which blocks all POST, PATCH and DELETE requests. +Likewise, to provide write-only access, inherit from `JsonApiCommandController`, which blocks all GET and HEAD requests. -For more information about resource injection, see the next section titled Resource Services. +You can even make your own mix of allowed routes by calling the alternate constructor of `JsonApiController` and injecting the set of service implementations available. +In some cases, resources may be an aggregation of entities or a view on top of the underlying entities. In these cases, there may not be a writable `IResourceService` implementation, so simply inject the implementation that is available. ```c# -public class ReportsController : BaseJsonApiController +public class ReportsController : JsonApiController { - public ReportsController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public ReportsController(IJsonApiOptions options, IResourceGraph resourceGraph, + ILoggerFactory loggerFactory, IGetAllService getAllService) + : base(options, resourceGraph, loggerFactory, getAll: getAllService) { } - - [HttpGet] - public override async Task GetAsync(CancellationToken cancellationToken) - { - return await base.GetAsync(cancellationToken); - } } ``` + +For more information about resource service injection, see [Replacing injected services](~/usage/extensibility/layer-overview.md#replacing-injected-services) and [Resource Services](~/usage/extensibility/services.md). diff --git a/docs/usage/extensibility/layer-overview.md b/docs/usage/extensibility/layer-overview.md index 8afd1b38bb..9233508179 100644 --- a/docs/usage/extensibility/layer-overview.md +++ b/docs/usage/extensibility/layer-overview.md @@ -23,20 +23,18 @@ 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 through dependency injection in your Startup.cs file. +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. ```c# -// Startup.cs -public void ConfigureServices(IServiceCollection services) -{ - services.AddResourceService(); - services.AddResourceRepository(); - services.AddResourceDefinition(); - - services.AddScoped(); - services.AddScoped(); -} +// Program.cs +builder.Services.AddResourceService(); +builder.Services.AddResourceRepository(); +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 9be350250a..dbbe81699f 100644 --- a/docs/usage/extensibility/middleware.md +++ b/docs/usage/extensibility/middleware.md @@ -3,53 +3,61 @@ 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. ```c# -/// In Startup.ConfigureServices -services.AddService(); +// Program.cs +builder.Services.AddService(); ``` ## Configuring `MvcOptions` -The following example replaces all internal filters with a custom filter. +The following example replaces the built-in query string action filter with a custom filter. ```c# -public class Startup +// Program.cs + +// Add services to the container. + +builder.Services.AddScoped(); + +IMvcCoreBuilder mvcCoreBuilder = builder.Services.AddMvcCore(); +builder.Services.AddJsonApi(mvcBuilder: mvcCoreBuilder); + +Action? postConfigureMvcOptions = null; + +// Ensure this is placed after the AddJsonApi() call. +mvcCoreBuilder.AddMvcOptions(mvcOptions => +{ + postConfigureMvcOptions?.Invoke(mvcOptions); +}); + +// Configure the HTTP request pipeline. + +// Ensure this is placed before the MapControllers() call. +postConfigureMvcOptions = mvcOptions => { - private Action _postConfigureMvcOptions; - - public void ConfigureServices(IServiceCollection services) - { - services.AddSingleton(); - - IMvcCoreBuilder builder = services.AddMvcCore(); - services.AddJsonApi(mvcBuilder: builder); - - // Ensure this call is placed after the AddJsonApi call. - builder.AddMvcOptions(mvcOptions => - { - _postConfigureMvcOptions.Invoke(mvcOptions); - }); - } - - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) - { - // Ensure this call is placed before the UseEndpoints call. - _postConfigureMvcOptions = mvcOptions => - { - mvcOptions.Filters.Clear(); - mvcOptions.Filters.Insert(0, - app.ApplicationServices.GetService()); - }; - - app.UseRouting(); - app.UseJsonApi(); - app.UseEndpoints(endpoints => endpoints.MapControllers()); - } -} + IFilterMetadata existingFilter = mvcOptions.Filters.Single(filter => + filter is ServiceFilterAttribute serviceFilter && + serviceFilter.ServiceType == typeof(IAsyncQueryStringActionFilter)); + + mvcOptions.Filters.Remove(existingFilter); + + using IServiceScope scope = app.Services.CreateScope(); + + var newFilter = + scope.ServiceProvider.GetRequiredService(); + + mvcOptions.Filters.Insert(0, newFilter); +}; + +app.UseRouting(); +app.UseJsonApi(); +app.MapControllers(); + +app.Run(); ``` diff --git a/docs/usage/extensibility/query-strings.md b/docs/usage/extensibility/query-strings.md index 1411717ffd..83d9f2e0ea 100644 --- a/docs/usage/extensibility/query-strings.md +++ b/docs/usage/extensibility/query-strings.md @@ -24,15 +24,17 @@ See [here](~/usage/extensibility/resource-definitions.md#custom-query-string-par In order to add parsing of custom query string parameters, you can implement the `IQueryStringParameterReader` interface and register your reader. ```c# -public class YourQueryStringParameterReader : IQueryStringParameterReader +public class CustomQueryStringParameterReader : IQueryStringParameterReader { // ... } ``` ```c# -services.AddScoped(); -services.AddScoped(sp => sp.GetService()); +// Program.cs +builder.Services.AddScoped(); +builder.Services.AddScoped(serviceProvider => + serviceProvider.GetRequiredService()); ``` Now you can inject your custom reader in resource services, repositories, resource definitions etc. diff --git a/docs/usage/extensibility/repositories.md b/docs/usage/extensibility/repositories.md index 623c959510..733634bf33 100644 --- a/docs/usage/extensibility/repositories.md +++ b/docs/usage/extensibility/repositories.md @@ -3,49 +3,41 @@ If you want to use a data access technology other than Entity Framework Core, you can create an implementation of `IResourceRepository`. If you only need minor changes you can override the methods defined in `EntityFrameworkCoreRepository`. -The repository should then be registered in Startup.cs. - ```c# -public void ConfigureServices(IServiceCollection services) -{ - services.AddScoped, ArticleRepository>(); - services.AddScoped, ArticleRepository>(); - services.AddScoped, ArticleRepository>(); -} +// Program.cs +builder.Services.AddScoped, ArticleRepository>(); +builder.Services.AddScoped, ArticleRepository>(); +builder.Services.AddScoped, ArticleRepository>(); ``` 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# -public class Startup -{ - public void ConfigureServices(IServiceCollection services) - { - services.AddResourceRepository(); - } -} +// 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. ```c# -public class ArticleRepository : EntityFrameworkCoreRepository
+public class ArticleRepository : EntityFrameworkCoreRepository { private readonly IAuthenticationService _authenticationService; public ArticleRepository(IAuthenticationService authenticationService, - ITargetedFields targetedFields, IDbContextResolver contextResolver, - IResourceGraph resourceGraph, IGenericServiceFactory genericServiceFactory, - IResourceFactory resourceFactory, + ITargetedFields targetedFields, IDbContextResolver dbContextResolver, + IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, - ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, - resourceFactory, constraintProviders, loggerFactory) + ILoggerFactory loggerFactory, + IResourceDefinitionAccessor resourceDefinitionAccessor) + : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, + constraintProviders, loggerFactory, resourceDefinitionAccessor) { _authenticationService = authenticationService; } @@ -64,18 +56,18 @@ If you need to use multiple Entity Framework Core DbContexts, first create a rep This example shows a single `DbContextARepository` for all entities that are members of `DbContextA`. ```c# -public class DbContextARepository : EntityFrameworkCoreRepository - where TResource : class, IIdentifiable +public class DbContextARepository + : EntityFrameworkCoreRepository + where TResource : class, IIdentifiable { public DbContextARepository(ITargetedFields targetedFields, - DbContextResolver contextResolver, + DbContextResolver dbContextResolver, // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - IResourceGraph resourceGraph, IGenericServiceFactory genericServiceFactory, - IResourceFactory resourceFactory, + IResourceGraph resourceGraph, IResourceFactory resourceFactory, IEnumerable constraintProviders, - ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, genericServiceFactory, - resourceFactory, constraintProviders, loggerFactory) + ILoggerFactory loggerFactory, IResourceDefinitionAccessor resourceDefinitionAccessor) + : base(targetedFields, dbContextResolver, resourceGraph, resourceFactory, + constraintProviders, loggerFactory, resourceDefinitionAccessor) { } } @@ -84,13 +76,12 @@ public class DbContextARepository : EntityFrameworkCoreRepository(options => options.UseSqlite("Data Source=A.db")); -services.AddDbContext(options => options.UseSqlite("Data Source=B.db")); +// Program.cs +builder.Services.AddDbContext(options => options.UseSqlite("Data Source=A.db")); +builder.Services.AddDbContext(options => options.UseSqlite("Data Source=B.db")); -services.AddScoped, DbContextARepository>(); -services.AddScoped, DbContextBRepository>(); +builder.Services.AddJsonApi(dbContextTypes: new[] { typeof(DbContextA), typeof(DbContextB) }); -services.AddJsonApi(dbContextTypes: new[] { typeof(DbContextA), typeof(DbContextB) }); +builder.Services.AddScoped, DbContextARepository>(); +builder.Services.AddScoped, DbContextBRepository>(); ``` diff --git a/docs/usage/extensibility/resource-definitions.md b/docs/usage/extensibility/resource-definitions.md index adf156ba1f..644d43fb75 100644 --- a/docs/usage/extensibility/resource-definitions.md +++ b/docs/usage/extensibility/resource-definitions.md @@ -7,45 +7,41 @@ 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# -public class Startup -{ - public void ConfigureServices(IServiceCollection services) - { - services.AddResourceDefinition(); - } -} +// 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# -services.AddScoped, ProductResource>(); -``` +> [!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 _since v4.0_ For various reasons (see examples below) you may need to change parts of the query, depending on resource type. -`JsonApiResourceDefinition` (which is an empty implementation of `IResourceDefinition`) provides overridable methods that pass you the result of query string parameter parsing. +`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 +public class UserDefinition : JsonApiResourceDefinition { public UserDefinition(IResourceGraph resourceGraph) : base(resourceGraph) @@ -104,7 +100,7 @@ Content-Type: application/vnd.api+json You can define the default sort order if no `sort` query string parameter is provided. ```c# -public class AccountDefinition : JsonApiResourceDefinition +public class AccountDefinition : JsonApiResourceDefinition { public AccountDefinition(IResourceGraph resourceGraph) : base(resourceGraph) @@ -132,7 +128,7 @@ public class AccountDefinition : JsonApiResourceDefinition You may want to enforce pagination on large database tables. ```c# -public class AccessLogDefinition : JsonApiResourceDefinition +public class AccessLogDefinition : JsonApiResourceDefinition { public AccessLogDefinition(IResourceGraph resourceGraph) : base(resourceGraph) @@ -163,7 +159,7 @@ public class AccessLogDefinition : JsonApiResourceDefinition The next example filters out `Account` resources that are suspended. ```c# -public class AccountDefinition : JsonApiResourceDefinition +public class AccountDefinition : JsonApiResourceDefinition { public AccountDefinition(IResourceGraph resourceGraph) : base(resourceGraph) @@ -172,11 +168,8 @@ public class AccountDefinition : JsonApiResourceDefinition public override FilterExpression OnApplyFilter(FilterExpression existingFilter) { - var resourceContext = ResourceGraph.GetResourceContext(); - - var isSuspendedAttribute = - resourceContext.Attributes.Single(account => - account.Property.Name == nameof(Account.IsSuspended)); + var isSuspendedAttribute = ResourceType.Attributes.Single(account => + account.Property.Name == nameof(Account.IsSuspended)); var isNotSuspended = new ComparisonExpression(ComparisonOperator.Equals, new ResourceFieldChainExpression(isSuspendedAttribute), @@ -195,20 +188,20 @@ public class AccountDefinition : JsonApiResourceDefinition In the example below, an error is returned when a user tries to include the manager of an employee. ```c# -public class EmployeeDefinition : JsonApiResourceDefinition +public class EmployeeDefinition : JsonApiResourceDefinition { public EmployeeDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } - public override IReadOnlyCollection OnApplyIncludes( - IReadOnlyCollection existingIncludes) + public override IImmutableList OnApplyIncludes( + IImmutableList existingIncludes) { if (existingIncludes.Any(include => include.Relationship.Property.Name == nameof(Employee.Manager))) { - throw new JsonApiException(new Error(HttpStatusCode.BadRequest) + throw new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest) { Title = "Including the manager of employees is not permitted." }); @@ -226,11 +219,12 @@ _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 EF 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# -public class ItemDefinition : JsonApiResourceDefinition +public class ItemDefinition : JsonApiResourceDefinition { public ItemDefinition(IResourceGraph resourceGraph) : base(resourceGraph) diff --git a/docs/usage/extensibility/services.md b/docs/usage/extensibility/services.md index 5b9beb5fdd..90dea1352b 100644 --- a/docs/usage/extensibility/services.md +++ b/docs/usage/extensibility/services.md @@ -1,17 +1,18 @@ # Resource Services The `IResourceService` acts as a service layer between the controller and the data access layer. -This allows you to customize it however you want. This is also a good place to implement custom business logic. +This allows you to customize it however you want. While this is still a potential place to implement custom business logic, +since v4, [Resource Definitions](~/usage/extensibility/resource-definitions.md) are more suitable for that. ## Supplementing Default Behavior -If you don't need to alter the underlying mechanisms, you can inherit from `JsonApiResourceService` and override the existing methods. +If you don't need to alter the underlying mechanisms, you can inherit from `JsonApiResourceService` and override the existing methods. In simple cases, you can also just wrap the base implementation with your custom logic. A simple example would be to send notifications when a resource gets created. ```c# -public class TodoItemService : JsonApiResourceService +public class TodoItemService : JsonApiResourceService { private readonly INotificationService _notificationService; @@ -19,9 +20,10 @@ public class TodoItemService : JsonApiResourceService IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, - IResourceHookExecutorFacade hookExecutor) + IResourceDefinitionAccessor resourceDefinitionAccessor, + INotificationService notificationService) : base(repositoryAccessor, queryLayerComposer, paginationContext, options, - loggerFactory, request, resourceChangeTracker, hookExecutor) + loggerFactory, request, resourceChangeTracker, resourceDefinitionAccessor) { _notificationService = notificationService; } @@ -43,21 +45,20 @@ public class TodoItemService : JsonApiResourceService ## Not Using Entity Framework Core? As previously discussed, this library uses Entity Framework Core by default. -If you'd like to use another ORM that does not provide what JsonApiResourceService depends upon, you can use a custom `IResourceService` implementation. +If you'd like to use another ORM that does not provide what JsonApiResourceService depends upon, you can use a custom `IResourceService` implementation. ```c# -// Startup.cs -public void ConfigureServices(IServiceCollection services) -{ - // add the service override for Product - services.AddScoped, ProductService>(); +// Program.cs - // add your own Data Access Object - services.AddScoped(); -} +// Add the service override for Product. +builder.Services.AddScoped, ProductService>(); + +// Add your own Data Access Object. +builder.Services.AddScoped(); // ProductService.cs -public class ProductService : IResourceService + +public class ProductService : IResourceService { private readonly IProductDao _dao; @@ -76,104 +77,85 @@ public class ProductService : IResourceService ## Limited Requirements -In some cases it may be necessary to only expose a few methods on a resource. For this reason, we have created a hierarchy of service interfaces that can be used to get the exact implementation you require. +In some cases it may be necessary to only expose a few actions on a resource. For this reason, we have created a hierarchy of service interfaces that can be used to get the exact implementation you require. 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. ```c# -public class ArticleService : ICreateService
, IDeleteService
+public class ArticleService : ICreateService, IDeleteService { // ... } -public class Startup -{ - public void ConfigureServices(IServiceCollection services) - { - services.AddScoped, ArticleService>(); - services.AddScoped, ArticleService>(); - } -} +// Program.cs +builder.Services.AddScoped, ArticleService>(); +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# -public class Startup +[Resource(GenerateControllerEndpoints = + JsonApiEndpoints.Create | JsonApiEndpoints.Delete)] +public class Article : Identifiable { - public void ConfigureServices(IServiceCollection services) - { - services.AddResourceService(); - } + // ... } ``` -Then in the controller, you should inherit from the base controller and pass the services into the named, optional base parameters: +Alternatively, when using a hand-written controller, you should inherit from the JSON:API controller and pass the services into the named, optional base parameters: ```c# -public class ArticlesController : BaseJsonApiController
+public class ArticlesController : JsonApiController { - public ArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, - ICreateService create, IDeleteService delete) - : base(options, loggerFactory, create: create, delete: delete) - { - } - - [HttpPost] - public override async Task PostAsync([FromBody] Article resource, - CancellationToken cancellationToken) - { - return await base.PostAsync(resource, cancellationToken); - } - - [HttpDelete("{id}")] - public override async TaskDeleteAsync(int id, - CancellationToken cancellationToken) + public ArticlesController(IJsonApiOptions options, IResourceGraph resourceGraph, + ILoggerFactory loggerFactory, ICreateService create, + IDeleteService delete) + : base(options, resourceGraph, loggerFactory, create: create, delete: delete) { - return await base.DeleteAsync(id, cancellationToken); } } ``` 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 6f052103e4..674d39413b 100644 --- a/docs/usage/meta.md +++ b/docs/usage/meta.md @@ -8,14 +8,17 @@ Global metadata can be added to the root of the response document by registering This is useful if you need access to other registered services to build the meta object. ```c# -// In Startup.ConfigureServices -services.AddSingleton(); +// Program.cs +builder.Services.AddSingleton(); +// CopyrightResponseMeta.cs + +#nullable enable public sealed class CopyrightResponseMeta : IResponseMeta { - public IReadOnlyDictionary GetMeta() + public IReadOnlyDictionary GetMeta() { - return new Dictionary + return new Dictionary { ["copyright"] = "Copyright (C) 2002 Umbrella Corporation.", ["authors"] = new[] { "Alice", "Red Queen" } @@ -39,24 +42,26 @@ public sealed class CopyrightResponseMeta : IResponseMeta ## Resource Meta -Resource-specific metadata can be added by implementing `IResourceDefinition.GetMeta` (or overriding it on `JsonApiResourceDefinition`): +Resource-specific metadata can be added by implementing `IResourceDefinition.GetMeta` (or overriding it on `JsonApiResourceDefinition`): ```c# -public class PersonDefinition : JsonApiResourceDefinition +#nullable enable + +public class PersonDefinition : JsonApiResourceDefinition { public PersonDefinition(IResourceGraph resourceGraph) : base(resourceGraph) { } - public override IReadOnlyDictionary GetMeta(Person person) + public override IReadOnlyDictionary? GetMeta(Person person) { if (person.IsEmployee) { - return new Dictionary + return new Dictionary { - ["notice"] = "Check our intranet at http://www.example.com/employees/" + - person.StringId + " for personal details." + ["notice"] = "Check our intranet at https://www.example.com/employees/" + + $"{person.StringId} for personal details." }; } @@ -75,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 287c1c52f1..7e89ff0090 100644 --- a/docs/usage/options.md +++ b/docs/usage/options.md @@ -1,36 +1,53 @@ # Global Options -Configuration can be applied when adding the services to the dependency injection container. +Configuration can be applied when adding services to the dependency injection container at startup. ```c# -public class Startup +// Program.cs +builder.Services.AddJsonApi(options => { - // This method gets called by the runtime. Use this method to add services to the container. - public void ConfigureServices(IServiceCollection services) - { - services.AddJsonApi(options => - { - // configure the options here - }); - } -} + // Configure the options here... +}); ``` -## 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.ClientIdGeneration = ClientIdGenerationMode.Allowed; +``` + +or: + +```c# +options.ClientIdGeneration = ClientIdGenerationMode.Required; +``` + +It is possible to overrule this setting per resource type: ```c# -options.AllowClientGeneratedIds = true; +[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); @@ -39,9 +56,12 @@ options.MaximumPageNumber = new PageNumber(50); 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 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; @@ -54,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" } } } @@ -78,43 +98,53 @@ To limit the maximum depth of nested includes, use `MaximumIncludeDepth`. This i options.MaximumIncludeDepth = 1; ``` -## Custom Serializer Settings +## Customize Serializer options -We use [Newtonsoft.Json](https://www.newtonsoft.com/json) for all serialization needs. -If you want to change the default serializer settings, you can: +We use [System.Text.Json](https://www.nuget.org/packages/System.Text.Json) for all serialization needs. +If you want to change the default serializer options, you can: ```c# -options.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; -options.SerializerSettings.Converters.Add(new StringEnumConverter()); -options.SerializerSettings.Formatting = Formatting.Indented; +options.SerializerOptions.WriteIndented = true; +options.SerializerOptions.ReferenceHandler = ReferenceHandler.Preserve; +options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); ``` The default naming convention (as used in the routes and resource/attribute/relationship names) is also determined here, and can be changed (default is camel-case): ```c# -options.SerializerSettings.ContractResolver = new DefaultContractResolver -{ - NamingStrategy = new KebabCaseNamingStrategy() -}; +// Use Pascal case +options.SerializerOptions.PropertyNamingPolicy = null; +options.SerializerOptions.DictionaryKeyPolicy = null; ``` -Because we copy resource properties into an intermediate object before serialization, Newtonsoft.Json annotations on properties are ignored. +Because we copy resource properties into an intermediate object before serialization, JSON annotations such as `[JsonPropertyName]` and `[JsonIgnore]` on `[Attr]` properties are ignored. + +## ModelState Validation -## Enable ModelState Validation +[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. -If you would like to use ASP.NET Core ModelState validation into your controllers when creating / updating resources, set `ValidateModelState` to `true`. By default, no model validation is performed. +How nullability affects ModelState validation is described [here](~/usage/resources/nullability.md). ```c# options.ValidateModelState = true; ``` ```c# -public class Person : Identifiable +#nullable enable + +public class Person : Identifiable { [Attr] - [Required] [MinLength(3)] - public string FirstName { get; set; } + public string FirstName { get; set; } = null!; + + [Attr] + [Required] + public int? Age { get; set; } + + [HasOne] + public LoginAccount Account { get; set; } = null!; } ``` diff --git a/docs/usage/reading/filtering.md b/docs/usage/reading/filtering.md index 1d98bee35d..05c3066644 100644 --- a/docs/usage/reading/filtering.md +++ b/docs/usage/reading/filtering.md @@ -24,6 +24,7 @@ Expressions are composed using the following functions: | Ends with text | `endsWith` | `?filter=endsWith(description,'End')` | | Equals one value from set | `any` | `?filter=any(chapter,'Intro','Summary','Conclusion')` | | Collection contains items | `has` | `?filter=has(articles)` | +| Type-check derived type (v5) | `isType` | `?filter=isType(,men)` | | Negation | `not` | `?filter=not(equals(lastName,null))` | | Conditional logical OR | `or` | `?filter=or(has(orders),has(invoices))` | | Conditional logical AND | `and` | `?filter=and(has(orders),has(invoices))` | @@ -42,7 +43,7 @@ GET /users?filter=equals(displayName,null) HTTP/1.1 GET /users?filter=equals(displayName,lastName) HTTP/1.1 ``` -Comparison operators can be combined with the `count` function, which acts on HasMany relationships: +Comparison operators can be combined with the `count` function, which acts on to-many relationships: ```http GET /blogs?filter=lessThan(count(owner.articles),'10') HTTP/1.1 @@ -59,15 +60,18 @@ 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), -filtering on included collections can be done using bracket notation: +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 -GET /articles?include=author,tags&filter=equals(author.lastName,'Smith')&filter[tags]=contains(label,'tech','design') HTTP/1.1 +GET /articles?include=author,tags&filter=equals(author.lastName,'Smith')&filter[tags]=any(label,'tech','design') HTTP/1.1 ``` 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. +> [!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: ```http @@ -84,6 +88,32 @@ GET /customers?filter=has(orders,not(equals(status,'Paid'))) HTTP/1.1 Which returns only customers that have at least one unpaid order. +_since v5.0_ + +Use the `isType` filter function to perform a type check on a derived type. You can pass a nested filter, where the derived fields are accessible. + +Only return men: +```http +GET /humans?filter=isType(,men) HTTP/1.1 +``` + +Only return men with beards: +```http +GET /humans?filter=isType(,men,equals(hasBeard,'true')) HTTP/1.1 +``` + +The first parameter of `isType` can be used to perform the type check on a to-one relationship path. + +Only return people whose best friend is a man with children: +```http +GET /humans?filter=isType(bestFriend,men,has(children)) HTTP/1.1 +``` + +Only return people who have at least one female married child: +```http +GET /humans?filter=has(children,isType(,woman,not(equals(husband,null)))) HTTP/1.1 +``` + # Legacy filters The next section describes how filtering worked in versions prior to v4.0. They are always applied on the set of resources being requested (no nesting). @@ -112,7 +142,7 @@ Examples can be found in the table below. Filters can be combined and will be applied using an OR operator. This used to be AND in versions prior to v4.0. -Attributes to filter on can optionally be prefixed with a HasOne relationship, for example: +Attributes to filter on can optionally be prefixed with to-one relationships, for example: ```http GET /api/articles?include=author&filter[caption]=like:marketing&filter[author.lastName]=Smith HTTP/1.1 @@ -167,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 dba43fcb9f..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 @@ -62,7 +62,7 @@ which is equivalent to: GET /api/articles?include=author&include=author.livingAddress&include=author.livingAddress.country ``` -This can be used on nested endpoints too: +This can be used on secondary endpoints too: ```http GET /api/blogs/1/articles?include=author.livingAddress.country diff --git a/docs/usage/reading/pagination.md b/docs/usage/reading/pagination.md index 77772288b3..ea4e30e621 100644 --- a/docs/usage/reading/pagination.md +++ b/docs/usage/reading/pagination.md @@ -6,9 +6,7 @@ Resources can be paginated. This request would fetch the second page of 10 artic GET /articles?page[size]=10&page[number]=2 HTTP/1.1 ``` -## Nesting - -Pagination can be used on nested endpoints, such as: +Pagination can be used on secondary endpoints, such as: ```http GET /blogs/1/articles?page[number]=2 HTTP/1.1 diff --git a/docs/usage/reading/sorting.md b/docs/usage/reading/sorting.md index dfadc325fa..707720e8d9 100644 --- a/docs/usage/reading/sorting.md +++ b/docs/usage/reading/sorting.md @@ -34,9 +34,9 @@ GET /api/blogs?sort=count(articles) HTTP/1.1 This sorts the list of blogs by their number of articles. -## Nesting +## Secondary endpoints -Sorting can be used on nested endpoints, such as: +Sorting can be used on secondary endpoints, such as: ```http GET /api/blogs/1/articles?sort=caption HTTP/1.1 diff --git a/docs/usage/reading/sparse-fieldset-selection.md b/docs/usage/reading/sparse-fieldset-selection.md index 7d90bf9d26..6491cb050b 100644 --- a/docs/usage/reading/sparse-fieldset-selection.md +++ b/docs/usage/reading/sparse-fieldset-selection.md @@ -2,15 +2,15 @@ As an alternative to returning all fields (attributes and relationships) from a resource, the `fields[]` query string parameter can be used to select a subset. Put the resource type to apply the fieldset on between the brackets. -This can be used on the resource being requested, as well as on nested endpoints and/or included resources. +This can be used on primary and secondary endpoints. The selection is applied on both primary and included resources. -Top-level example: +Primary endpoint example: ```http GET /articles?fields[articles]=title,body,comments HTTP/1.1 ``` -Nested endpoint example: +Secondary endpoint example: ```http GET /api/blogs/1/articles?fields[articles]=title,body,comments HTTP/1.1 @@ -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 c77c842429..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. @@ -23,36 +24,27 @@ Auto-discovery refers to the process of reflecting on an assembly and detecting all of the JSON:API resources, resource definitions, resource services and repositories. The following command builds the resource graph using all `IIdentifiable` implementations and registers the services mentioned. -You can enable auto-discovery for the current assembly by adding the following to your `Startup` class. +You can enable auto-discovery for the current assembly by adding the following at startup. ```c# -// Startup.cs -public void ConfigureServices(IServiceCollection services) -{ - services.AddJsonApi(discovery => discovery.AddCurrentAssembly()); -} +// Program.cs +builder.Services.AddJsonApi(discovery: discovery => discovery.AddCurrentAssembly()); ``` ### Specifying an Entity Framework Core DbContext -If you are using Entity Framework Core as your ORM, you can add all the models of a `DbContext` to the resource graph. +If you are using Entity Framework Core as your ORM, you can add all the models of a `DbContext` to the resource graph. ```c# -// Startup.cs -public void ConfigureServices(IServiceCollection services) -{ - services.AddJsonApi(); -} +// Program.cs +builder.Services.AddJsonApi(); ``` Be aware that this does not register resource definitions, resource services and repositories. You can combine it with auto-discovery to achieve this. ```c# -// Startup.cs -public void ConfigureServices(IServiceCollection services) -{ - services.AddJsonApi(discovery => discovery.AddCurrentAssembly()); -} +// Program.cs +builder.Services.AddJsonApi(discovery: discovery => discovery.AddCurrentAssembly()); ``` ### Manual Specification @@ -60,42 +52,38 @@ public void ConfigureServices(IServiceCollection services) You can manually construct the graph. ```c# -// Startup.cs -public void ConfigureServices(IServiceCollection services) -{ - services.AddJsonApi(resources: builder => - { - builder.Add(); - }); -} +// Program.cs +builder.Services.AddJsonApi(resources: resourceGraphBuilder => + resourceGraphBuilder.Add()); ``` ## Resource Name -The public resource name is exposed through the `type` member in the JSON:API payload. This can be configured by the following approaches (in order of priority): +The public resource name is exposed through the `type` member in the JSON:API payload. This can be configured using the following approaches (in order of priority): -1. The `publicName` parameter when manually adding a resource to the graph +1. The `publicName` parameter when manually adding a resource to the graph. ```c# -services.AddJsonApi(resources: builder => +// Program.cs +builder.Services.AddJsonApi(resources: resourceGraphBuilder => { - builder.Add(publicName: "people"); + resourceGraphBuilder.Add(publicName: "individuals"); }); ``` -2. The model is decorated with a `ResourceAttribute` +2. The `PublicName` property when a model is decorated with a `ResourceAttribute`. ```c# -[Resource("myResources")] -public class MyModel : Identifiable +[Resource(PublicName = "individuals")] +public class Person : Identifiable { } ``` -3. The configured naming convention (by default this is camel-case). +3. The configured naming convention (by default this is camel-case), after pluralization. ```c# -// this will be registered as "myModels" -public class MyModel : Identifiable +// this will be registered as "people" +public class Person : Identifiable { } ``` -The default naming convention can be changed in [options](~/usage/options.md#custom-serializer-settings). +The default naming convention can be changed in [options](~/usage/options.md#customize-serializer-options). diff --git a/docs/usage/resources/attributes.md b/docs/usage/resources/attributes.md index 6e24ab964f..77c6ff9566 100644 --- a/docs/usage/resources/attributes.md +++ b/docs/usage/resources/attributes.md @@ -3,10 +3,15 @@ If you want an attribute on your model to be publicly available, add the `AttrAttribute`. ```c# -public class Person : Identifiable +#nullable enable + +public class Person : Identifiable { [Attr] - public string FirstName { get; set; } + public string? FirstName { get; set; } + + [Attr] + public string LastName { get; set; } = null!; } ``` @@ -14,14 +19,15 @@ public class Person : Identifiable There are two ways the exposed attribute name is determined: -1. Using the configured [naming convention](~/usage/options.md#custom-serializer-settings). +1. Using the configured [naming convention](~/usage/options.md#customize-serializer-options). 2. Individually using the attribute's constructor. ```c# -public class Person : Identifiable +#nullable enable +public class Person : Identifiable { [Attr(PublicName = "first-name")] - public string FirstName { get; set; } + public string? FirstName { get; set; } } ``` @@ -37,72 +43,98 @@ 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# -public class User : Identifiable +#nullable enable + +public class User : Identifiable { [Attr(Capabilities = ~AttrCapabilities.AllowView)] - public string Password { get; set; } + public string Password { get; set; } = null!; } ``` -### 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# -public class Person : Identifiable +#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# -public class Person : Identifiable +#nullable enable + +public class Person : Identifiable { - [Attr(Capabilities = AttrCapabilities.AllowChange)] - public string FirstName { get; set; } + [Attr(Capabilities = ~AttrCapabilities.AllowSort)] + public string? FirstName { get; set; } +} +``` + +### AllowCreate + +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.AllowCreate)] + public string? CreatorName { get; set; } } ``` -### Filter/Sort-ability +### AllowChange -Attributes can be marked to allow filtering and/or sorting. When not allowed, it results in an HTTP 400 response. +Indicates whether PATCH requests can update the attribute value. When sent but not allowed, an HTTP 422 response is returned. ```c# -public class Person : Identifiable +#nullable enable + +public class Person : Identifiable { - [Attr(Capabilities = AttrCapabilities.AllowSort | AttrCapabilities.AllowFilter)] - public string FirstName { get; set; } + [Attr(Capabilities = AttrCapabilities.AllowChange)] + public string? FirstName { get; set; }; } ``` ## Complex Attributes Models may contain complex attributes. -Serialization of these types is done by [Newtonsoft.Json](https://www.newtonsoft.com/json), -so you should use their APIs to specify serialization formats. -You can also use global options to specify `JsonSerializer` configuration. +Serialization of these types is done by [System.Text.Json](https://www.nuget.org/packages/System.Text.Json), +so you should use their APIs to specify serialization format. +You can also use [global options](~/usage/options.md#customize-serializer-options) to control the `JsonSerializer` behavior. ```c# -public class Foo : Identifiable +#nullable enable + +public class Foo : Identifiable { [Attr] - public Bar Bar { get; set; } + public Bar? Bar { get; set; } } public class Bar { - [JsonProperty("compound-member")] - public string CompoundMember { get; set; } + [JsonPropertyName("compound-member")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? CompoundMember { get; set; } } ``` @@ -112,22 +144,25 @@ The first member is the concrete type that you will directly interact with in yo and retrieval. ```c# -public class Foo : Identifiable +#nullable enable + +public class Foo : Identifiable { - [Attr, NotMapped] - public Bar Bar { get; set; } + [Attr] + [NotMapped] + public Bar? Bar { get; set; } - public string BarJson + public string? BarJson { get { - return Bar == null ? "{}" : JsonConvert.SerializeObject(Bar); + return Bar == null ? "{}" : JsonSerializer.Serialize(Bar); } set { Bar = string.IsNullOrWhiteSpace(value) ? null - : JsonConvert.DeserializeObject(value); + : JsonSerializer.Deserialize(value); } } } diff --git a/docs/usage/resources/hooks.md b/docs/usage/resources/hooks.md deleted file mode 100644 index e34c4922fd..0000000000 --- a/docs/usage/resources/hooks.md +++ /dev/null @@ -1,771 +0,0 @@ - - -# Resource Hooks -This section covers the usage of **Resource Hooks**, which is a feature of`ResourceHooksDefinition`. See the [ResourceDefinition usage guide](~/usage/extensibility/resource-definitions.md) for a general explanation on how to set up a `JsonApiResourceDefinition`. For a quick start, jump right to the [Getting started: most minimal example](#getting-started-most-minimal-example) section. - -> Note: Resource Hooks are an experimental feature and are turned off by default. They are subject to change or be replaced in a future version. - -By implementing resource hooks on a `ResourceHooksDefintion`, it is possible to intercept the execution of the **Resource Service Layer** (RSL) in various ways. This enables the developer to conveniently define business logic without having to override the RSL. It can be used to implement e.g. -* Authorization -* [Event-based synchronisation between microservices](https://docs.microsoft.com/en-us/dotnet/standard/microservices-architecture/multi-container-microservice-net-applications/integration-event-based-microservice-communications) -* Logging -* Transformation of the served data - -This usage guide covers the following sections -1. [**Semantics: pipelines, actions and hooks**](#1-semantics-pipelines-actions-and-hooks) -Understanding the semantics will be helpful in identifying which hooks on `ResourceHooksDefinition` you need to implement for your use-case. -2. [**Basic usage**](#2-basic-usage) - Some examples to get you started. - * [**Getting started: most minimal example**](#getting-started-most-minimal-example) - * [**Logging**](#logging) - * [**Transforming data with OnReturn**](#transforming-data-with-onreturn) - * [**Loading database values**](#loading-database-values) -3. [**Advanced usage**](#3-advanced-usage) - Complicated examples that show the advanced features of hooks. - * [**Simple authorization: explicitly affected resources**](#simple-authorization-explicitly-affected-resources) - * [**Advanced authorization: implicitly affected resources**](#advanced-authorization-implicitly-affected-resources) - * [**Synchronizing data across microservices**](#synchronizing-data-across-microservices) - * [**Hooks for many-to-many join tables**](#hooks-for-many-to-many-join-tables) -5. [**Hook execution overview**](#4-hook-execution-overview) - A table overview of all pipelines and involved hooks - -# 1. Semantics: pipelines, actions and hooks - -## Pipelines -The different execution flows within the RSL that may be intercepted can be identified as **pipelines**. Examples of such pipelines are -* **Post**: creation of a resource (triggered by the endpoint `POST /my-resource`). -* **PostBulk**: creation of multiple resources (triggered by the endpoint `POST /bulk/my-resource`). - * *NB: hooks are not yet supported with bulk operations.* -* **Get**: reading a resource (triggered by the endpoint `GET /my-resource`). -* **GetSingle**: reading a single resource (triggered by the endpoint `GET /my-resource/1`). - -See the [ResourcePipeline](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/745d2fb6b6c9dd21ff794284a193977fdc699fe6/src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourcePipeline.cs) enum for a full list of available pipelines. - -## Actions -Each pipeline is associated with a set of **actions** that work on resources and their relationships. These actions reflect the associated database operations that are performed by JsonApiDotNetCore (in the Repository Layer). Typically, the RSL will execute some service-layer-related code, then invoke the Repository Layer which will perform these actions, after which the execution returns to the RSL. - -Note that some actions are shared across different pipelines, and note that most pipelines perform multiple actions. There are two types of actions: **primary resource actions** and **nested resource actions**. - -### Primary resource actions -Most actions are trivial in the context of the pipeline where they're executed from. They may be recognised as the familiar *CRUD* operations of an API. These actions are: - -* The `create` action: the **Post** pipeline will `create` a resource -* The `read` action: the **Get** and **GetSingle** pipeline will `read` (a) resource(s). -* The `update` action: the **Patch** pipeline will `update` a resource. -* The `delete` action: the **Delete** pipeline will `delete` a resource. - -These actions are called the **primary resource actions** of a particular pipeline because **they act on the request resource**. For example, when an `Article` is created through the **Post** pipeline, its main action, `create`, will work on that `Article`. - -### Nested Resource Actions -Some other actions might be overlooked, namely the nested resource actions. These actions are - -* `update relationship` for directly affected relationships -* `implicit update relationship` for implicitly affected relationships -* `read` for included relationships - -These actions are called **nested resource actions** of a particular pipeline because **they act on involved (nested) resources** instead of the primary request resource. For example, when loading articles and their respective authors (`GET /articles?include=author`), the `read` action on `Article` is the primary action, and the `read` action on `Person` is the nested action. - -#### The `update relationship` action -[As per the Json:Api specification](https://jsonapi.org/format/#crud-creating](https://jsonapi.org/format/#crud-creating), the **Post** pipeline also allows for an `update relationship` action on an already existing resource. For example, when creating an `Article` it is possible to simultaneously relate it to an existing `Person` by setting its author. In this case, the `update relationship` action is a nested action that will work on that `Person`. - -#### The `implicit update relationship` action -the **Delete** pipeline also allows for an `implicit update relationship` action on an already existing resource. For example, for an `Article` that its author property assigned to a particular `Person`, the relationship between them is destroyed when this article is deleted. This update is "implicit" in the sense that no explicit information about that `Person` was provided in the request that triggered this pipeline. An `implicit update relationship` action is therefore performed on that `Person`. See [this section](#advanced-authorization-implicitly-affected-resources) for a more detailed explanation. - -### Shared actions -Note that **some actions are shared across pipelines**. For example, both the **Post** and **Patch** pipeline can perform the `update relationship` action on an (already existing) involved resource. Similarly, the **Get** and **GetSingle** pipelines perform the same `read` action. -

-For a complete list of actions associated with each pipeline, see the [overview table](#4-hook-execution-overview). - -## Hooks -For all actions it is possible to implement **at least one hook** to intercept its execution. These hooks can be implemented by overriding the corresponding virtual implementation on `ResourceHooksDefintion`. (Note that the base implementation is a dummy implementation, which is ignored when firing hooks.) - -### Action related hooks -As an example, consider the `create` action for the `Article` Resource. This action can be intercepted by overriding the -* `ResourceHooksDefinition
.BeforeCreate` hook for custom logic **just before** execution of the main `create` action -* `ResourceHooksDefinition
.AfterCreate` hook for custom logic **just after** execution of the main `create` action - -If with the creation of an `Article` a relationship to `Person` is updated simultaneously, this can be intercepted by overriding the -* `ResourceHooksDefinition.BeforeUpdateRelationship` hook for custom logic **just before** the execution of the nested `update relationship` action. -* `ResourceHooksDefinition.AfterUpdateRelationship` hook for custom logic **just after** the execution of the nested `update relationship` action. - -### OnReturn hook -As mentioned in the previous section, some actions are shared across hooks. One of these actions is the `return` action. Although not strictly compatible with the *CRUD* vocabulary, and although not executed by the Repository Layer, pipelines are also said to perform a `return` action when any content is to be returned from the API. For example, the **Delete** pipeline does not return any content, but a *HTTP 204 No Content* instead, and will therefore not perform a `return` action. On the contrary, the **Get** pipeline does return content, and will therefore perform a `return action` - -Any return content can be intercepted and transformed as desired by implementing the `ResourceHooksDefinition.OnReturn` hook which intercepts the `return` action. For this action, there is no distinction between a `Before` and `After` hook, because no code after a `return` statement can be evaluated. Note that the `return` action can work on *primary resources as well as nested resources*, see [this example below](#transforming-data-with-onreturn). -

-For an overview of all pipelines, hooks and actions, see the table below, and for more detailed information about the available hooks, see the [IResourceHookContainer](https://github.com/json-api-dotnet/JsonApiDotNetCore/blob/ab1f96d8255532461da47d290c5440b9e7e6a4a5/src/JsonApiDotNetCore/Hooks/IResourceHookContainer.cs) interface. - -# 2. Basic usage - -## Getting started: most minimal example -To use resource hooks, you are required to turn them on in your `startup.cs` configuration - -```c# -public void ConfigureServices(IServiceCollection services) -{ - // ... - - services.AddJsonApi(options => - { - options.EnableResourceHooks = true; // default is false - options.LoadDatabaseValues = false; // default is false - }); - - // ... -} -``` - -For this example, we may set `LoadDatabaseValues` to `false`. See the [Loading database values](#loading-database-values) example for more information about this option. - -The simplest case of resource hooks we can then implement should not require a lot of explanation. This hook would be triggered by any default JsonApiDotNetCore API route for `Article`. - -```c# -public class ArticleResource : ResourceHooksDefinition
-{ - public override IEnumerable
OnReturn(HashSet
entities, - ResourcePipeline pipeline) - { - Console.WriteLine("This hook does not do much apart from writing this message" + - " to the console just before serving the content."); - return entities; - } -} -``` - -## Logging -This example shows how some actions can be logged on the level of API users. - -First consider the following scoped service which creates a logger bound to a particular user and request. - -```c# -/// This is a scoped service, which means log will have a request-based -/// unique id associated to it. -public class UserActionsLogger : IUserActionsLogger -{ - public ILogger Instance { get; private set; } - - public UserActionsLogger(ILoggerFactory loggerFactory, IUserService userService) - { - var userId = userService.GetUser().Id; - Instance = - loggerFactory.CreateLogger($"[request: {Guid.NewGuid()}" + "user: {userId}]"); - } -} -``` - -Now, let's assume our application has two resources: `Article` and `Person`, and that there exist a one-to-one and one-to-many relationship between them (`Article` has one `Author` and `Article` has many `Reviewers`). Let's assume we are required to log the following events: -* An API user deletes an article -* An API user removes the `Author` relationship of a person -* An API user removes the `Reviewer` relationship of a person - -This could be achieved in the following way: - -```c# -/// Note that resource definitions are also registered as scoped services. -public class ArticleResource : ResourceHooksDefinition
-{ - private readonly ILogger _userLogger; - - public ArticleResource(IUserActionsLogger logService) - { - _userLogger = logService.Instance; - } - - public override void AfterDelete(HashSet
entities, ResourcePipeline pipeline, - bool succeeded) - { - if (!succeeded) - { - return; - } - - foreach (Article article in entities) - { - _userLogger.Log(LogLevel.Information, - $"Deleted article '{article.Name}' with id {article.Id}"); - } - } -} - -public class PersonResource : ResourceHooksDefinition -{ - private readonly ILogger _userLogger; - - public PersonResource(IUserActionsLogger logService) - { - _userLogger = logService.Instance; - } - - public override void AfterUpdateRelationship( - IAffectedRelationships resourcesByRelationship, ResourcePipeline pipeline) - { - var updatedRelationshipsToArticle = relationshipHelper.EntitiesRelatedTo
(); - - foreach (var updated in updatedRelationshipsToArticle) - { - RelationshipAttribute relationship = updated.Key; - HashSet affectedEntities = updated.Value; - - foreach (Person person in affectedEntities) - { - if (pipeline == ResourcePipeline.Delete) - { - _userLogger.Log(LogLevel.Information, - $"Deleted the {relationship.PublicRelationshipName} relationship " + - $"to Article for person '{person.FirstName} {person.LastName}' " + - $"with id {person.Id}"); - } - } - } - } -} -``` - -If eg. an API user deletes an article with title *JSON:API paints my bikeshed!* that had related as author *John Doe* and as reviewer *Frank Miller*, the logs generated logs would look something like - -``` -[request: 186190e3-1900-4329-9181-42082258e7b4, user: dd1cd99d-60e9-45ca-8d03-a0330b07bdec] Deleted article 'JSON:API paints my bikeshed!' with id fac0436b-7aa5-488e-9de7-dbe00ff8f04d -[request: 186190e3-1900-4329-9181-42082258e7b4, user: dd1cd99d-60e9-45ca-8d03-a0330b07bdec] Deleted the author relationship to Article for person 'John Doe' with id 2ec3990d-c816-4d6d-8531-7da4a030d4d0 -[request: 186190e3-1900-4329-9181-42082258e7b4, user: dd1cd99d-60e9-45ca-8d03-a0330b07bdec] Deleted the reviewer relationship to Article for person 'Frank Miller' with id 42ad6eb2-b813-4261-8fc1-0db1233e665f -``` - -## Transforming data with OnReturn -Using the `OnReturn` hook, any set of resources can be manipulated as desired before serving it from the API. One of the use-cases for this is being able to perform a [filtered include](https://github.com/aspnet/EntityFrameworkCore/issues/1833), which is currently not supported by Entity Framework Core. - -As an example, consider again an application with the `Article` and `Person` resource, and let's assume the following business rules: -* when reading `Article`s, we never want to show articles for which the `IsSoftDeleted` property is set to true. -* when reading `Person`s, we never want to show people who wish to remain anonymous (`IsAnonymous` is set to true). - -This can be achieved as follows: - -```c# -public class ArticleResource : ResourceHooksDefinition
-{ - public override IEnumerable
OnReturn(HashSet
entities, - ResourcePipeline pipeline) - { - return entities.Where(article => !article.IsSoftDeleted); - } -} - -public class PersonResource : ResourceHooksDefinition -{ - public override IEnumerable OnReturn(HashSet entities, - ResourcePipeline pipeline) - { - if (pipeline == ResourcePipeline.Get) - { - return entities.Where(person => !person.IsAnonymous); - } - return entities; - } -} -``` - -Note that not only anonymous people will be excluded when directly performing a `GET /people`, but also when included through relationships, like `GET /articles?include=author,reviewers`. Simultaneously, `if` condition that checks for `ResourcePipeline.Get` in the `PersonResource` ensures we still get expected responses from the API when eg. creating a person with `WantsPrivacy` set to true. - -## Loading database values -When a hook is executed for a particular resource, JsonApiDotNetCore can load the corresponding database values and provide them in the hooks. This can be useful for eg. - * having a diff between a previous and new state of a resource (for example when updating a resource) - * performing authorization rules based on the property of a resource. - -For example, consider a scenario with the following two requirements: -* We need to log all updates on resources revealing their old and new value. -* We need to check if the property `IsLocked` is set is `true`, and if so, cancel the operation. - -Consider an `Article` with title *Hello there* and API user trying to update the the title of this article to *Bye bye*. The above requirements could be implemented as follows: - -```c# -public class ArticleResource : ResourceHooksDefinition
-{ - private readonly ILogger _logger; - private readonly ITargetedFields _targetedFields; - - public constructor ArticleResource(ILogger logger, ITargetedFields targetedFields) - { - _logger = logger; - _targetedFields = targetedFields; - } - - public override IEnumerable
BeforeUpdate(IResourceDiff
entityDiff, - ResourcePipeline pipeline) - { - // PropertyGetter is a helper class that takes care of accessing the values - // on an instance of Article using reflection. - var getter = new PropertyGetter
(); - - // ResourceDiff is like a list that contains ResourceDiffPair elements - foreach (ResourceDiffPair
affected in entityDiff) - { - // the current state in the database - var currentDatabaseState = affected.DatabaseValue; - - // the value from the request - var proposedValueFromRequest = affected.Entity; - - if (currentDatabaseState.IsLocked) - { - throw new JsonApiException(403, "Forbidden: this article is locked!") - } - - foreach (var attr in _targetedFields.Attributes) - { - var oldValue = getter(currentDatabaseState, attr); - var newValue = getter(proposedValueFromRequest, attr); - - _logger.LogAttributeUpdate(oldValue, newValue) - } - } - - // You must return IEnumerable
from this hook. - // This means that you could reduce the set of entities that is - // affected by this request, eg. by entityDiff.Entities.Where( ... ); - entityDiff.Entities; - } -} -``` - -In this case the `ResourceDiffPair.DatabaseValue` is `null`. If you try to access all database values at once (`ResourceDiff.DatabaseValues`) when it is turned off, an exception will be thrown. - -Note that database values are turned off by default. They can be turned on globally by configuring the startup as follows: - -```c# -public void ConfigureServices(IServiceCollection services) -{ - // ... - - services.AddJsonApi(options => - { - options.LoadDatabaseValues = true; - }); - - // ... -} -``` - -The global setting can be used together with per-hook configuration hooks using the `LoadDatabaseValues` attribute: - -```c# -public class ArticleResource : ResourceHooksDefinition
-{ - [LoadDatabaseValues(true)] - public override IEnumerable
BeforeUpdate(IResourceDiff
entityDiff, - ResourcePipeline pipeline) - { - // ... - } - - [LoadDatabaseValues(false)] - public override IEnumerable BeforeUpdateRelationships(HashSet ids, - IAffectedRelationships
resourcesByRelationship, ResourcePipeline pipeline) - { - // the entities stored in the IAffectedRelationships
instance - // are plain resource identifier objects when LoadDatabaseValues is turned off, - // or objects loaded from the database when LoadDatabaseValues is turned on. - } - } -} -``` - -Note that there are some hooks that the `LoadDatabaseValues` option and attribute does not affect. The only hooks that are affected are: -* `BeforeUpdate` -* `BeforeUpdateRelationship` -* `BeforeDelete` - - -# 3. Advanced usage - -## Simple authorization: explicitly affected resources -Resource hooks can be used to easily implement authorization in your application. As an example, consider the case in which an API user is not allowed to see anonymous people, which is reflected by the `Anonymous` property on `Person` being set to `true`. The API should handle this as follows: -* When reading people (`GET /people`), it should hide all people that are set to anonymous. -* When reading a single person (`GET /people/{id}`), it should throw an authorization error if the particular requested person is set to anonymous. - -This can be achieved as follows: - -```c# -public class PersonResource : ResourceHooksDefinition -{ - private readonly _IAuthorizationHelper _auth; - - public constructor PersonResource(IAuthorizationHelper auth) - { - // IAuthorizationHelper is a helper service that handles all authorization related logic. - _auth = auth; - } - - public override IEnumerable OnReturn(HashSet entities, - ResourcePipeline pipeline) - { - if (!_auth.CanSeeSecretPeople()) - { - if (pipeline == ResourcePipeline.GetSingle) - { - throw new JsonApiException(403, "Forbidden to view this person", - new UnauthorizedAccessException()); - } - - entities = entities.Where(person => !person.IsSecret) - } - - return entities; - } -} -``` - -This example of authorization is considered simple because it only involves one resource. The next example shows a more complex case. - -## Advanced authorization: implicitly affected resources -Let's consider an authorization scenario for which we are required to implement multiple hooks across multiple resource definitions. We will assume the following: -* There exists a one-to-one relationship between `Article` and `Person`: an article can have only one author, and a person can be author of only one article. -* The author of article `Old Article` is person `Alice`. -* The author of article `New Article` is person `Bob`. - -Now let's consider an API user that tries to update `New Article` by setting its author to `Alice`. The request would look something like `PATCH /articles/{ArticleId}` with a body containing a reference to `Alice`. - -First to all, we wish to authorize this operation by the verifying permissions related to the resources that are **explicity affected** by it: -1. Is the API user allowed to update `Article`? -2. Is the API user allowed to update `Alice`? - -Apart from this, we also wish to verify permissions for the resources that are **implicitly affected** by this operation: `Bob` and `Old Article`. Setting `Alice` as the new author of `Article` will result in removing the following two relationships: `Bob` being an author of `Article`, and `Alice` being an author of `Old Article`. Therefore, we wish wish to verify the related permissions: - -3. Is the API user allowed to update `Bob`? -4. Is the API user allowed to update `Old Article`? - -This authorization requirement can be fulfilled as follows. - -For checking the permissions for the explicitly affected resources, `Article` and `Alice`, we may implement the `BeforeUpdate` hook for `Article`: - -```c# -public override IEnumerable
BeforeUpdate(IResourceDiff
entityDiff, - ResourcePipeline pipeline) -{ - if (pipeline == ResourcePipeline.Patch) - { - Article article = entityDiff.RequestEntities.Single(); - - if (!_auth.CanEditResource(article)) - { - throw new JsonApiException(403, "Forbidden to update properties of this article", - new UnauthorizedAccessException()); - } - - if (entityDiff.GetByRelationship().Any() && - _auth.CanEditRelationship(article)) - { - throw new JsonApiException(403, "Forbidden to update relationship of this article", - new UnauthorizedAccessException()); - } - } - - return entityDiff.RequestEntities; -} -``` - -and the `BeforeUpdateRelationship` hook for `Person`: - -```c# -public override IEnumerable BeforeUpdateRelationship(HashSet ids, - IAffectedRelationships resourcesByRelationship, ResourcePipeline pipeline) -{ - var updatedOwnerships = resourcesByRelationship.GetByRelationship
(); - - if (updatedOwnerships.Any()) - { - Person person = - resourcesByRelationship.GetByRelationship
().Single().Value.First(); - - if (_auth.CanEditRelationship
(person)) - { - throw new JsonApiException(403, "Forbidden to update relationship of this person", - new UnauthorizedAccessException()); - } - } - - return ids; -} -``` - -To verify the permissions for the implicitly affected resources, `Old Article` and `Bob`, we need to implement the `BeforeImplicitUpdateRelationship` hook for `Article`: - -```c# -public override void BeforeImplicitUpdateRelationship( - IAffectedRelationships
resourcesByRelationship, ResourcePipeline pipeline) -{ - var updatedOwnerships = resourcesByRelationship.GetByRelationship(); - - if (updatedOwnerships.Any()) - { - Article article = - resourcesByRelationship.GetByRelationship().Single().Value.First(); - - if (_auth.CanEditRelationship(article)) - { - throw new JsonApiException(403, "Forbidden to update relationship of this article", - new UnauthorizedAccessException()); - } - } -} -``` - -and similarly for `Person`: - -```c# -public override void BeforeImplicitUpdateRelationship( - IAffectedRelationships resourcesByRelationship, ResourcePipeline pipeline) -{ - var updatedOwnerships = resourcesByRelationship.GetByRelationship
(); - if (updatedOwnerships.Any()) - { - Person person = - resourcesByRelationship.GetByRelationship
().Single().Value.First(); - - if (_auth.CanEditRelationship
(person)) - { - throw new JsonApiException(403, "Forbidden to update relationship of this article", - new UnauthorizedAccessException()); - } - } -} -``` - -## Using Resource Hooks without Entity Framework Core - -If you want to use Resource Hooks without Entity Framework Core, there are several things that you need to consider that need to be met. For any resource that you want to use hooks for: -1. The corresponding resource repository must fully implement `IResourceReadRepository` -2. If you are using custom services, you will be responsible for injecting the `IResourceHookExecutor` service into your services and call the appropriate methods. See the [hook execution overview](#4-hook-execution-overview) to determine which hook should be fired in which scenario. - -If you are required to use the `BeforeImplicitUpdateRelationship` hook (see previous example), there is an additional requirement. For this hook, given a particular relationship, JsonApiDotNetCore needs to be able to resolve the inverse relationship. For example: if `Article` has one author (a `Person`), then it needs to be able to resolve the `RelationshipAttribute` that corresponds to the inverse relationship for the `author` property. There are two approaches : - -1. **Tell JsonApiDotNetCore how to do this only for the relevant models**. If you're using the `BeforeImplicitUpdateRelationship` hook only for a small set of models, eg only for the relationship of the example, then it is easiest to provide the `inverseNavigationProperty` as follows: - -```c# -public class Article : Identifiable -{ - [HasOne("author", InverseNavigationProperty: "OwnerOfArticle")] - public Person Author { get; set; } -} - -public class Person : Identifiable -{ - [HasOne("article")] - public Article OwnerOfArticle { get; set; } -} -``` - -2. **Tell JsonApiDotNetCore how to do this in general**. For full support, you can provide JsonApiDotNetCore with a custom service implementation of the `IInverseNavigationResolver` interface. relationship of the example, then it is easiest to provide the `InverseNavigationProperty` as follows: - -```c# -public class CustomInverseNavigationResolver : IInverseNavigationResolver -{ - public void Resolve() - { - // the implementation of this method depends completely on - // the data access layer you're using. - // It should set the RelationshipAttribute.InverseNavigationProperty property - // for all (relevant) relationships. - // To have an idea of how to implement this method, see the InverseNavigationResolver class - // in the source code of JsonApiDotNetCore. - } -} -``` - -This service will then be run once at startup and take care of the metadata that is required for `BeforeImplicitUpdateRelationship` to be supported. - -*Note: don't forget to register this singleton service with the service provider.* - - -## Synchronizing data across microservices -If your application is built using a microservices infrastructure, it may be relevant to propagate data changes between microservices, [see this article for more information](https://docs.microsoft.com/en-us/dotnet/standard/microservices-architecture/multi-container-microservice-net-applications/integration-event-based-microservice-communications). In this example, we will assume the implementation of an event bus and we will publish data consistency integration events using resource hooks. - -```c# -public class ArticleResource : ResourceHooksDefinition
-{ - private readonly IEventBus _bus; - private readonly IJsonApiContext _context; - - public ArticleResource(IEventBus bus, IJsonApiContext context) - { - _bus = bus; - _context = context; - } - - public override void AfterCreate(HashSet
entities, ResourcePipeline pipeline) - { - foreach (var article in entities ) - { - var @event = new ResourceCreatedEvent(article); - _bus.Publish(@event); - } - } - - public override void AfterDelete(HashSet
entities, ResourcePipeline pipeline, - bool succeeded) - { - foreach (var article in entities) - { - var @event = new ResourceDeletedEvent(article); - _bus.Publish(@event); - } - } - - public override void AfterUpdate(HashSet
entities, ResourcePipeline pipeline) - { - foreach (var article in entities) - { - // You could inject ITargetedFields and use it to pass along - // only the attributes that were updated - - var @event = new ResourceUpdatedEvent(article, - properties: _targetedFields.Attributes); - - _bus.Publish(@event); - } - } -} -``` - -## Hooks for many-to-many join tables -In this example we consider an application with a many-to-many relationship: `Article` and `Tag`, with an internally used `ArticleTag` join-type. - -Usually, join table records will not contain any extra information other than that which is used internally for the many-to-many relationship. For this example, the join-type should then look like: - -```c# -public class ArticleTag -{ - public int ArticleId { get; set; } - public Article Article { get; set; } - - public int TagId { get; set; } - public Tag Tag { get; set; } -} -``` - -If we then eg. implement the `AfterRead` and `OnReturn` hook for `Article` and `Tag`, and perform a `GET /articles?include=tags` request, we may expect the following order of execution: - -1. Article AfterRead -2. Tag AfterRead -3. Article OnReturn -4. Tag OnReturn - -Note that under the hood, the *join table records* (instances of `ArticleTag`) are also being read, but we did not implement any hooks for them. In this example, for these records, there is little relevant business logic that can be thought of. - -Sometimes, however, relevant data may be stored in the join table of a many-to-many relationship. Let's imagine we wish to add a property `LinkDate` to the join table that reflects when a tag was added to an article. In this case, we may want to execute business logic related to these records: we may for example want to hide any tags that were added to an article longer than 2 weeks ago. - -In order to achieve this, we need to change `ArticleTag` to `ArticleTagWithLinkDate` as follows: - -```c# -public class ArticleTagWithLinkDate : Identifiable -{ - public int ArticleId { get; set; } - - [HasOne("Article")] - public Article Article { get; set; } - - public int TagId { get; set; } - - [HasOne("Tag")] - public Tag Tag { get; set; } - - public DateTime LinkDate { get; set; } -} -``` - -Then, we may implement a hook for `ArticleTagWithLinkDate` as usual: - -```c# -public class ArticleTagWithLinkDateResource : ResourceHooksDefinition -{ - public override IEnumerable OnReturn( - HashSet entities, ResourcePipeline pipeline) - { - return entities.Where(article => (DateTime.Now - article.LinkDate) < 14); - } -} -``` - -Then, for the same request `GET /articles?include=tags`, the order of execution of the hooks will look like: -1. Article AfterRead -2. Tag AfterRead -3. Article OnReturn -4. ArticleTagWithLinkDate OnReturn -5. Tag OnReturn - -And the included collection of tags per article will only contain tags that were added less than two weeks ago. - -Note that the introduced inheritance and added relationship attributes does not further affect the many-to-many relationship internally. - -# 4. Hook execution overview - - -This table below shows the involved hooks per pipeline. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
PipelineExecution Flow
Before HooksRepository ActionsAfter HooksOnReturn
GetBeforeReadreadAfterRead[x]
GetSingleBeforeReadAfterRead[x]
GetRelationshipBeforeReadAfterRead[x]
PostBeforeCreatecreate
update relationship
AfterCreate[x]
PatchBeforeUpdate
BeforeUpdateRelationship
BeforeImplicitUpdateRelationship
update
update relationship
implicit update relationship
AfterUpdate
AfterUpdateRelationship
[x]
PatchRelationshipBeforeUpdate
BeforeUpdateRelationship
update
update relationship
implicit update relationship
AfterUpdate
AfterUpdateRelationship
[ ]
DeleteBeforeDeletedelete
implicit update relationship
AfterDelete[ ]
BulkPostNot yet supported
BulkPatchNot yet supported
BulkDeleteNot yet supported
diff --git a/docs/usage/resources/index.md b/docs/usage/resources/index.md index 29f510e543..09e0224c57 100644 --- a/docs/usage/resources/index.md +++ b/docs/usage/resources/index.md @@ -8,25 +8,13 @@ public class Person : Identifiable } ``` -You can use the non-generic `Identifiable` if your primary key is an integer. +> [!NOTE] +> Earlier versions of JsonApiDotNetCore allowed a short-hand notation when `TId` is of type `int`. This was removed in v5. -```c# -public class Person : Identifiable -{ -} - -// is the same as: - -public class Person : Identifiable -{ -} -``` - -If you need to attach annotations or attributes on the `Id` property, -you can override the virtual property. +If you need to attach annotations or attributes on the `Id` property, you can override the virtual property. ```c# -public class Person : Identifiable +public class Person : Identifiable { [Key] [Column("PersonID")] @@ -34,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 new file mode 100644 index 0000000000..56c046ef82 --- /dev/null +++ b/docs/usage/resources/inheritance.md @@ -0,0 +1,409 @@ +# Resource inheritance + +_since v5.0_ + +Resource classes can be part of a type hierarchy. For example: + +```c# +#nullable enable + +public abstract class Human : Identifiable +{ + [Attr] + public string Name { get; set; } = null!; + + [HasOne] + public Man? Father { get; set; } + + [HasOne] + public Woman? Mother { get; set; } + + [HasMany] + public ISet Children { get; set; } = new HashSet(); + + [HasOne] + public Human? BestFriend { get; set; } +} + +public sealed class Man : Human +{ + [Attr] + public bool HasBeard { get; set; } + + [HasOne] + public Woman? Wife { get; set; } +} + +public sealed class Woman : Human +{ + [Attr] + public string? MaidenName { get; set; } + + [HasOne] + public Man? Husband { get; set; } +} +``` + +## Reading data + +You can access them through base or derived endpoints. + +```http +GET /humans HTTP/1.1 + +{ + "data": [ + { + "type": "women", + "id": "1", + "attributes": { + "maidenName": "Smith", + "name": "Jane Doe" + }, + "relationships": { + "husband": { + "links": { + "self": "/women/1/relationships/husband", + "related": "/women/1/husband" + } + }, + "father": { + "links": { + "self": "/women/1/relationships/father", + "related": "/women/1/father" + } + }, + "mother": { + "links": { + "self": "/women/1/relationships/mother", + "related": "/women/1/mother" + } + }, + "children": { + "links": { + "self": "/women/1/relationships/children", + "related": "/women/1/children" + } + }, + "bestFriend": { + "links": { + "self": "/women/1/relationships/bestFriend", + "related": "/women/1/bestFriend" + } + } + }, + "links": { + "self": "/women/1" + } + }, + { + "type": "men", + "id": "2", + "attributes": { + "hasBeard": true, + "name": "John Doe" + }, + "relationships": { + "wife": { + "links": { + "self": "/men/2/relationships/wife", + "related": "/men/2/wife" + } + }, + "father": { + "links": { + "self": "/men/2/relationships/father", + "related": "/men/2/father" + } + }, + "mother": { + "links": { + "self": "/men/2/relationships/mother", + "related": "/men/2/mother" + } + }, + "children": { + "links": { + "self": "/men/2/relationships/children", + "related": "/men/2/children" + } + }, + "bestFriend": { + "links": { + "self": "/men/2/relationships/bestFriend", + "related": "/men/2/bestFriend" + } + } + }, + "links": { + "self": "/men/2" + } + } + ] +} +``` + +### 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). + +```http +GET /humans?fields[men]=name,children&fields[women]=name,children HTTP/1.1 +``` + +### Includes + +Relationships on derived types can be included without special syntax. + +```http +GET /humans?include=husband,wife,children HTTP/1.1 +``` + +### Sorting + +Just like includes, you can sort on derived attributes and relationships. + +```http +GET /humans?sort=maidenName,wife.name HTTP/1.1 +``` + +This returns all women sorted by their maiden names, followed by all men sorted by the name of their wife. + +To accomplish the same from a [Resource Definition](~/usage/extensibility/resource-definitions.md), upcast to the derived type: + +```c# +public override SortExpression OnApplySort(SortExpression? existingSort) +{ + return CreateSortExpressionFromLambda(new PropertySortOrder + { + (human => ((Woman)human).MaidenName, ListSortDirection.Ascending), + (human => ((Man)human).Wife!.Name, ListSortDirection.Ascending) + }); +} +``` + +### Filtering + +Use the `isType` filter function to perform a type check on a derived type. You can pass a nested filter, where the derived fields are accessible. + +Only return men: +```http +GET /humans?filter=isType(,men) HTTP/1.1 +``` + +Only return men with beards: +```http +GET /humans?filter=isType(,men,equals(hasBeard,'true')) HTTP/1.1 +``` + +The first parameter of `isType` can be used to perform the type check on a to-one relationship path. + +Only return people whose best friend is a man with children: +```http +GET /humans?filter=isType(bestFriend,men,has(children)) HTTP/1.1 +``` + +Only return people who have at least one female married child: +```http +GET /humans?filter=has(children,isType(,woman,not(equals(husband,null)))) HTTP/1.1 +``` + +## Writing data + +Just like reading data, you can use base or derived endpoints. When using relationships in request bodies, you can use base or derived types as well. +The only exception is that you cannot use an abstract base type in the request body when creating or updating a resource. + +For example, updating an attribute and relationship can be done at an abstract endpoint, but its body requires non-abstract types: + +```http +PATCH /humans/2 HTTP/1.1 + +{ + "data": { + "type": "men", + "id": "2", + "attributes": { + "hasBeard": false + }, + "relationships": { + "wife": { + "data": { + "type": "women", + "id": "1" + } + } + } + } +} +``` + +Updating a relationship does allow abstract types. For example: + +```http +PATCH /humans/1/relationships/children HTTP/1.1 + +{ + "data": [ + { + "type": "humans", + "id": "2" + } + ] +} +``` + +### Request pipeline + +The `TResource` type parameter used in controllers, resource services and resource repositories always matches the used endpoint. +But when JsonApiDotNetCore sees usage of a type from a type hierarchy, it fetches the stored types and updates `IJsonApiRequest` accordingly. +As a result, `TResource` can be different from what `IJsonApiRequest.PrimaryResourceType` returns. + +For example, on the request: +```http + GET /humans/1 HTTP/1.1 +``` + +JsonApiDotNetCore runs `IResourceService`, but `IJsonApiRequest.PrimaryResourceType` returns `Woman` +if human with ID 1 is stored as a woman in the underlying data store. + +Even with a simple type hierarchy as used here, lots of possible combinations quickly arise. For example, changing someone's best friend can be done using the following requests: +- `PATCH /humans/1/ { "data": { relationships: { bestFriend: { type: "women" ... } } } }` +- `PATCH /humans/1/ { "data": { relationships: { bestFriend: { type: "men" ... } } } }` +- `PATCH /women/1/ { "data": { relationships: { bestFriend: { type: "women" ... } } } }` +- `PATCH /women/1/ { "data": { relationships: { bestFriend: { type: "men" ... } } } }` +- `PATCH /men/2/ { "data": { relationships: { bestFriend: { type: "women" ... } } } }` +- `PATCH /men/2/ { "data": { relationships: { bestFriend: { type: "men" ... } } } }` +- `PATCH /humans/1/relationships/bestFriend { "data": { type: "human" ... } }` +- `PATCH /humans/1/relationships/bestFriend { "data": { type: "women" ... } }` +- `PATCH /humans/1/relationships/bestFriend { "data": { type: "men" ... } }` +- `PATCH /women/1/relationships/bestFriend { "data": { type: "human" ... } }` +- `PATCH /women/1/relationships/bestFriend { "data": { type: "women" ... } }` +- `PATCH /women/1/relationships/bestFriend { "data": { type: "men" ... } }` +- `PATCH /men/2/relationships/bestFriend { "data": { type: "human" ... } }` +- `PATCH /men/2/relationships/bestFriend { "data": { type: "women" ... } }` +- `PATCH /men/2/relationships/bestFriend { "data": { type: "men" ... } }` + +Because of all the possible combinations, implementing business rules in the pipeline is a no-go. +Resource definitions provide a better solution, see below. + +### Resource definitions + +In contrast to the request pipeline, JsonApiDotNetCore always executes the resource definition that matches the *stored* type. +This enables to implement business logic in a central place, irrespective of which endpoint was used or whether base types were used in relationships. + +To delegate logic for base types to their matching resource type, you can build a chain of resource definitions. And because you'll always get the +actually stored types (for relationships too), you can type-check left-side and right-side types in resources definitions. + +```c# +public sealed class HumanDefinition : JsonApiResourceDefinition +{ + public HumanDefinition(IResourceGraph resourceGraph) + : base(resourceGraph) + { + } + + public override Task OnSetToOneRelationshipAsync(Human leftResource, + HasOneAttribute hasOneRelationship, IIdentifiable? rightResourceId, + WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (leftResource is Man && + hasOneRelationship.Property.Name == nameof(Human.BestFriend) && + rightResourceId is Woman) + { + throw new Exception("Men are not supposed to have a female best friend."); + } + + return Task.FromResult(rightResourceId); + } + + public override Task OnWritingAsync(Human resource, WriteOperationKind writeOperation, + CancellationToken cancellationToken) + { + if (writeOperation is WriteOperationKind.CreateResource or + WriteOperationKind.UpdateResource) + { + if (resource is Man { HasBeard: true }) + { + throw new Exception("Only shaved men, please."); + } + } + + return Task.CompletedTask; + } +} + +public sealed class WomanDefinition : JsonApiResourceDefinition +{ + private readonly IResourceDefinition _baseDefinition; + + public WomanDefinition(IResourceGraph resourceGraph, + IResourceDefinition baseDefinition) + : base(resourceGraph) + { + _baseDefinition = baseDefinition; + } + + public override Task OnSetToOneRelationshipAsync(Woman leftResource, + HasOneAttribute hasOneRelationship, IIdentifiable? rightResourceId, + WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (ResourceType.BaseType!.FindRelationshipByPublicName( + hasOneRelationship.PublicName) != null) + { + // Delegate to resource definition for base type Human. + return _baseDefinition.OnSetToOneRelationshipAsync(leftResource, + hasOneRelationship, rightResourceId, writeOperation, cancellationToken); + } + + // Handle here. + if (hasOneRelationship.Property.Name == nameof(Woman.Husband) && + rightResourceId == null) + { + throw new Exception("We don't accept unmarried women at this time."); + } + + return Task.FromResult(rightResourceId); + } + + public override async Task OnPrepareWriteAsync(Woman resource, + WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + // Run rules in resource definition for base type Human. + await _baseDefinition.OnPrepareWriteAsync(resource, writeOperation, cancellationToken); + + // Run rules for type Woman. + if (resource.MaidenName == null) + { + throw new Exception("Women should have a maiden name."); + } + } +} + +public sealed class ManDefinition : JsonApiResourceDefinition +{ + private readonly IResourceDefinition _baseDefinition; + + public ManDefinition(IResourceGraph resourceGraph, + IResourceDefinition baseDefinition) + : base(resourceGraph) + { + _baseDefinition = baseDefinition; + } + + public override Task OnSetToOneRelationshipAsync(Man leftResource, + HasOneAttribute hasOneRelationship, IIdentifiable? rightResourceId, + WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + // No man-specific logic, but we'll still need to delegate. + return _baseDefinition.OnSetToOneRelationshipAsync(leftResource, hasOneRelationship, + rightResourceId, writeOperation, cancellationToken); + } + + public override Task OnWritingAsync(Man resource, WriteOperationKind writeOperation, + CancellationToken cancellationToken) + { + // No man-specific logic, but we'll still need to delegate. + return _baseDefinition.OnWritingAsync(resource, writeOperation, cancellationToken); + } +} +``` diff --git a/docs/usage/resources/nullability.md b/docs/usage/resources/nullability.md new file mode 100644 index 0000000000..875b133a01 --- /dev/null +++ b/docs/usage/resources/nullability.md @@ -0,0 +1,89 @@ +# Nullability in resources + +Properties on a resource class can be declared as nullable or non-nullable. This affects both ASP.NET ModelState validation and the way Entity Framework Core generates database columns. + +ModelState validation is enabled by default since v5.0. In earlier versions, it can be enabled in [options](~/usage/options.md#modelstate-validation). + +# Value types + +When ModelState validation is enabled, non-nullable value types will **not** trigger a validation error when omitted in the request body. +To make JsonApiDotNetCore return an error when such a property is missing on resource creation, declare it as nullable and annotate it with `[Required]`. + +Example: + +```c# +public sealed class User : Identifiable +{ + [Attr] + [Required] + public bool? IsAdministrator { get; set; } +} +``` + +This makes Entity Framework Core generate non-nullable columns. And model errors are returned when nullable fields are omitted. + +# Reference types + +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 + +When NRT is turned off, use `[Required]` on required attributes and relationships. This makes Entity Framework Core generate non-nullable columns. And model errors are returned when required fields are omitted. + +Example: + +```c# +#nullable disable + +public sealed class Label : Identifiable +{ + [Attr] + [Required] + public string Name { get; set; } + + [Attr] + public string RgbColor { get; set; } + + [HasOne] + [Required] + public Person Creator { get; set; } + + [HasOne] + public Label Parent { get; set; } + + [HasMany] + public ISet TodoItems { get; set; } +} +``` + +## NRT turned on + +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://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. + +Example: + +```c# +#nullable enable + +public sealed class Label : Identifiable +{ + [Attr] + public string Name { get; set; } = null!; + + [Attr] + public string? RgbColor { get; set; } + + [HasOne] + public Person Creator { get; set; } = null!; + + [HasOne] + public Label? Parent { get; set; } + + [HasMany] + public ISet TodoItems { get; set; } = new HashSet(); +} +``` diff --git a/docs/usage/resources/relationships.md b/docs/usage/resources/relationships.md index 2d515872e9..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 @@ -11,40 +11,221 @@ The left side of a relationship is where the relationship is declared, the right This exposes a to-one relationship. ```c# -public class TodoItem : Identifiable +#nullable enable + +public class TodoItem : Identifiable { [HasOne] - public Person Owner { get; set; } + public Person? Owner { get; set; } } ``` The left side of this relationship is of type `TodoItem` (public name: "todoItems") and the right side is of type `Person` (public name: "persons"). +### One-to-one relationships in Entity Framework Core + +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. + +```c# +#nullable enable + +public sealed class Car : Identifiable +{ + [HasOne] + public Engine Engine { get; set; } = null!; +} + +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(); + } +} +``` + +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"), + CONSTRAINT "FK_Cars_Engine_Id" FOREIGN KEY ("Id") REFERENCES "Engine" ("Id") + ON DELETE CASCADE +); +``` + +To fix this, name the foreign key explicitly: + +```c# +protected override void OnModelCreating(ModelBuilder builder) +{ + builder.Entity() + .HasOne(car => car.Engine) + .WithOne(engine => engine.Car) + .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, + CONSTRAINT "PK_Cars" PRIMARY KEY ("Id"), + 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"); +``` ## HasMany This exposes a to-many relationship. ```c# -public class Person : Identifiable +public class Person : Identifiable { [HasMany] - public ICollection TodoItems { get; set; } + public ICollection TodoItems { get; set; } = new HashSet(); } ``` The left side of this relationship is of type `Person` (public name: "persons") and the right side is of type `TodoItem` (public name: "todoItems"). - ## HasManyThrough +_removed since v5.0_ + Earlier versions of Entity Framework Core (up to v5) [did not support](https://github.com/aspnet/EntityFrameworkCore/issues/1368) many-to-many relationships without a join entity. -For this reason, we have decided to fill this gap by allowing applications to declare a relationship as `HasManyThrough`. -JsonApiDotNetCore will expose this relationship to the client the same way as any other `HasMany` relationship. -However, under the covers it will use the join type and Entity Framework Core's APIs to get and set the relationship. +For this reason, earlier versions of JsonApiDotNetCore filled this gap by allowing applications to declare a relationship as `HasManyThrough`, +which would expose the relationship to the client the same way as any other `HasMany` relationship. +However, under the covers it would use the join type and Entity Framework Core's APIs to get and set the relationship. ```c# -public class Article : Identifiable +#nullable disable + +public class Article : Identifiable { // tells Entity Framework Core to ignore this property [NotMapped] @@ -60,31 +241,138 @@ public class Article : Identifiable The left side of this relationship is of type `Article` (public name: "articles") and the right side is of type `Tag` (public name: "tags"). - ## Name There are two ways the exposed relationship name is determined: -1. Using the configured [naming convention](~/usage/options.md#custom-serializer-settings). +1. Using the configured [naming convention](~/usage/options.md#customize-serializer-options). 2. Individually using the attribute's constructor. ```c# -public class TodoItem : Identifiable +#nullable enable +public class TodoItem : Identifiable { [HasOne(PublicName = "item-owner")] - public Person Owner { get; set; } + public Person Owner { get; set; } = null!; } ``` -## 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. ```c# -public class TodoItem : Identifiable +#nullable enable + +public class TodoItem : Identifiable { [HasOne(CanInclude: false)] - public Person Owner { get; set; } + public Person? Owner { get; set; } } ``` @@ -96,25 +384,24 @@ Your resource may expose a calculated property, whose value depends on a related So for the calculated property to be evaluated correctly, the related entity must always be retrieved. You can achieve that using `EagerLoad`, for example: ```c# -public class ShippingAddress : Identifiable +#nullable enable + +public class ShippingAddress : Identifiable { [Attr] - public string Street { get; set; } + public string Street { get; set; } = null!; [Attr] - public string CountryName - { - get { return Country.DisplayName; } - } + public string? CountryName => Country?.DisplayName; // not exposed as resource, but adds .Include("Country") to the query [EagerLoad] - public Country Country { get; set; } + public Country? Country { get; set; } } public class Country { - public string IsoCode { get; set; } - public string DisplayName { get; set; } + public string IsoCode { get; set; } = null!; + public string DisplayName { get; set; } = null!; } ``` 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 314e2bdfb1..cb1197e86a 100644 --- a/docs/usage/routing.md +++ b/docs/usage/routing.md @@ -6,38 +6,40 @@ An endpoint URL provides access to a resource or a relationship. Resource endpoi In the relationship endpoint "/articles/1/relationships/comments", "articles" is the left side of the relationship and "comments" the right side. -## Namespacing and Versioning URLs -You can add a namespace to all URLs by specifying it in ConfigureServices. +## Namespacing and versioning of URLs +You can add a namespace to all URLs by specifying it at startup. ```c# -public void ConfigureServices(IServiceCollection services) -{ - services.AddJsonApi(options => options.Namespace = "api/v1"); -} +// Program.cs +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 +## Default routing convention -The library will configure routes for all controllers in your project. By default, routes are camel-cased. This is based on the [recommendations](https://jsonapi.org/recommendations/) outlined in the JSON:API spec. +The library will configure routes for all auto-generated and hand-written controllers in your project. By default, routes are camel-cased. This is based on the [recommendations](https://jsonapi.org/recommendations/) outlined in the JSON:API spec. ```c# -public class OrderLine : Identifiable +// Auto-generated +[Resource] +public class OrderSummary : Identifiable { } -public class OrderLineController : JsonApiController +// Hand-written +public class OrderLineController : JsonApiController { - public OrderLineController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, loggerFactory, resourceService) + public OrderLineController(IJsonApiOptions options, IResourceGraph resourceGraph, + ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } ``` ```http +GET /orderSummaries HTTP/1.1 GET /orderLines HTTP/1.1 ``` @@ -45,7 +47,7 @@ The exposed name of the resource ([which can be customized](~/usage/resource-gra ### Non-JSON:API controllers -If a controller does not inherit from `JsonApiController`, the [configured naming convention](~/usage/options.md#custom-serializer-settings) is applied to the name of the controller. +If a controller does not inherit from `JsonApiController`, the [configured naming convention](~/usage/options.md#customize-serializer-options) is applied to the name of the controller. ```c# public class OrderLineController : ControllerBase @@ -57,29 +59,36 @@ public class OrderLineController : ControllerBase GET /orderLines HTTP/1.1 ``` -## Disabling the Default Routing Convention +### Customized routes -It is possible to bypass the default routing convention for a controller. +It is possible to override the default routing convention for an auto-generated or hand-written controller. ```c# -[Route("v1/custom/route/lines-in-order"), DisableRoutingConvention] -public class OrderLineController : JsonApiController +// Auto-generated +[DisableRoutingConvention] +[Route("custom/route/summaries-for-orders")] +partial class OrderSummariesController { - public OrderLineController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IResourceService resourceService) - : base(options, loggerFactory, resourceService) +} + +// Hand-written +[DisableRoutingConvention] +[Route("custom/route/lines-in-order")] +public class OrderLineController : JsonApiController +{ + public OrderLineController(IJsonApiOptions options, IResourceGraph resourceGraph, + ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) { } } ``` -## Advanced Usage: Custom Routing Convention +## 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# -public void ConfigureServices(IServiceCollection services) -{ - services.AddSingleton(); -} +// Program.cs +builder.Services.AddSingleton(); ``` diff --git a/docs/usage/toc.md b/docs/usage/toc.md deleted file mode 100644 index a8b5473007..0000000000 --- a/docs/usage/toc.md +++ /dev/null @@ -1,32 +0,0 @@ -# [Resources](resources/index.md) -## [Attributes](resources/attributes.md) -## [Relationships](resources/relationships.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 bbf2db2ec2..c8ba2bf48e 100644 --- a/docs/usage/writing/bulk-batch-operations.md +++ b/docs/usage/writing/bulk-batch-operations.md @@ -17,15 +17,26 @@ To enable operations, add a controller to your project that inherits from `JsonA ```c# public sealed class OperationsController : JsonApiOperationsController { - public OperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, - IOperationsProcessor processor, IJsonApiRequest request, - ITargetedFields targetedFields) - : base(options, loggerFactory, processor, request, targetedFields) + public OperationsController(IJsonApiOptions options, IResourceGraph resourceGraph, + ILoggerFactory loggerFactory, IOperationsProcessor processor, + 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: ``` @@ -81,14 +92,15 @@ Content-Type: application/vnd.api+json;ext="https://jsonapi.org/ext/atomic" } ``` -For example requests, see our suite of tests in JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations. +For example requests, see our suite of tests in JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations. ## Configuration -The maximum number of operations per request defaults to 10, which you can change from Startup.cs: +The maximum number of operations per request defaults to 10, which you can change at startup: ```c# -services.AddJsonApi(options => options.MaximumOperationsPerRequest = 250); +// Program.cs +builder.Services.AddJsonApi(options => options.MaximumOperationsPerRequest = 250); ``` Or, if you want to allow unconstrained, set it to `null` instead. 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 ab4b9c95dd..21e96eac67 100644 --- a/inspectcode.ps1 +++ b/inspectcode.ps1 @@ -4,22 +4,16 @@ dotnet tool restore -if ($LASTEXITCODE -ne 0) { - throw "Tool restore failed with exit code $LASTEXITCODE" -} - -dotnet build -c Release - -if ($LASTEXITCODE -ne 0) { - throw "Build 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 --output="$outputPath" --profile=WarningSeverities.DotSettings --properties:Configuration=Release --severity=WARNING --verbosity=WARN -dsl=GlobalAll -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 fb7e448e0a..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:12.0 +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 new file mode 100644 index 0000000000..5bae37f9b9 --- /dev/null +++ b/src/Examples/DatabasePerTenantExample/Controllers/EmployeesController.cs @@ -0,0 +1,8 @@ +using JsonApiDotNetCore.Controllers.Annotations; +using Microsoft.AspNetCore.Mvc; + +namespace DatabasePerTenantExample.Controllers; + +[DisableRoutingConvention] +[Route("api/{tenantName}/employees")] +partial class EmployeesController; diff --git a/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs b/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs new file mode 100644 index 0000000000..7ff84c8e41 --- /dev/null +++ b/src/Examples/DatabasePerTenantExample/Data/AppDbContext.cs @@ -0,0 +1,74 @@ +using System.Net; +using DatabasePerTenantExample.Models; +using JetBrains.Annotations; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.EntityFrameworkCore; + +namespace DatabasePerTenantExample.Data; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class AppDbContext(DbContextOptions options, IHttpContextAccessor httpContextAccessor, IConfiguration configuration) + : DbContext(options) +{ + private readonly IHttpContextAccessor _httpContextAccessor = httpContextAccessor; + private readonly IConfiguration _configuration = configuration; + private string? _forcedTenantName; + + public DbSet Employees => Set(); + + public void SetTenantName(string tenantName) + { + _forcedTenantName = tenantName; + } + + protected override void OnConfiguring(DbContextOptionsBuilder builder) + { + string connectionString = GetConnectionString(); + builder.UseNpgsql(connectionString); + } + + private string GetConnectionString() + { + string? tenantName = GetTenantName(); + string? connectionString = _configuration.GetConnectionString(tenantName ?? "Default"); + + if (connectionString == null) + { + throw GetErrorForInvalidTenant(tenantName); + } + + return connectionString; + } + + private string? GetTenantName() + { + if (_forcedTenantName != null) + { + return _forcedTenantName; + } + + if (_httpContextAccessor.HttpContext != null) + { + string? tenantName = (string?)_httpContextAccessor.HttpContext.Request.RouteValues["tenantName"]; + + if (tenantName == null) + { + throw GetErrorForInvalidTenant(null); + } + + return tenantName; + } + + return null; + } + + private static JsonApiException GetErrorForInvalidTenant(string? tenantName) + { + return new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest) + { + Title = "Missing or invalid tenant in URL.", + Detail = $"Tenant '{tenantName}' does not exist." + }); + } +} diff --git a/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj b/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj new file mode 100644 index 0000000000..3edc993428 --- /dev/null +++ b/src/Examples/DatabasePerTenantExample/DatabasePerTenantExample.csproj @@ -0,0 +1,18 @@ + + + net9.0;net8.0 + + + + + + + + + + + + + + diff --git a/src/Examples/DatabasePerTenantExample/Models/Employee.cs b/src/Examples/DatabasePerTenantExample/Models/Employee.cs new file mode 100644 index 0000000000..cc79449880 --- /dev/null +++ b/src/Examples/DatabasePerTenantExample/Models/Employee.cs @@ -0,0 +1,19 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DatabasePerTenantExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class Employee : Identifiable +{ + [Attr] + public string FirstName { get; set; } = null!; + + [Attr] + public string LastName { get; set; } = null!; + + [Attr] + public string CompanyName { get; set; } = null!; +} diff --git a/src/Examples/DatabasePerTenantExample/Program.cs b/src/Examples/DatabasePerTenantExample/Program.cs new file mode 100644 index 0000000000..4b88357d78 --- /dev/null +++ b/src/Examples/DatabasePerTenantExample/Program.cs @@ -0,0 +1,75 @@ +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 => 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(); + +// Configure the HTTP request pipeline. + +app.UseRouting(); +app.UseJsonApi(); +app.MapControllers(); + +await CreateDatabaseAsync(null, app.Services); +await CreateDatabaseAsync("AdventureWorks", app.Services); +await CreateDatabaseAsync("Contoso", app.Services); + +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) +{ + await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + if (tenantName != null) + { + dbContext.SetTenantName(tenantName); + } + + if (await dbContext.Database.EnsureCreatedAsync()) + { + if (tenantName != null) + { + dbContext.Employees.Add(new Employee + { + FirstName = "John", + LastName = "Doe", + CompanyName = tenantName + }); + + await dbContext.SaveChangesAsync(); + } + } +} diff --git a/src/Examples/DatabasePerTenantExample/Properties/launchSettings.json b/src/Examples/DatabasePerTenantExample/Properties/launchSettings.json new file mode 100644 index 0000000000..43ae84e51e --- /dev/null +++ b/src/Examples/DatabasePerTenantExample/Properties/launchSettings.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:14147", + "sslPort": 44340 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "api/AdventureWorks/employees", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "Kestrel": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "api/AdventureWorks/employees", + "applicationUrl": "https://localhost:44347;http://localhost:14147", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Examples/DatabasePerTenantExample/appsettings.json b/src/Examples/DatabasePerTenantExample/appsettings.json new file mode 100644 index 0000000000..1b5a40da62 --- /dev/null +++ b/src/Examples/DatabasePerTenantExample/appsettings.json @@ -0,0 +1,17 @@ +{ + "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" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Examples/GettingStarted/Controllers/BooksController.cs b/src/Examples/GettingStarted/Controllers/BooksController.cs deleted file mode 100644 index 17e1c1417d..0000000000 --- a/src/Examples/GettingStarted/Controllers/BooksController.cs +++ /dev/null @@ -1,16 +0,0 @@ -using GettingStarted.Models; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace GettingStarted.Controllers -{ - public sealed class BooksController : JsonApiController - { - public BooksController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/src/Examples/GettingStarted/Controllers/PeopleController.cs b/src/Examples/GettingStarted/Controllers/PeopleController.cs deleted file mode 100644 index c7600be15a..0000000000 --- a/src/Examples/GettingStarted/Controllers/PeopleController.cs +++ /dev/null @@ -1,16 +0,0 @@ -using GettingStarted.Models; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace GettingStarted.Controllers -{ - public sealed class PeopleController : JsonApiController - { - public PeopleController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/src/Examples/GettingStarted/Data/SampleDbContext.cs b/src/Examples/GettingStarted/Data/SampleDbContext.cs index b54011ff14..cd8b16515d 100644 --- a/src/Examples/GettingStarted/Data/SampleDbContext.cs +++ b/src/Examples/GettingStarted/Data/SampleDbContext.cs @@ -2,21 +2,11 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; -namespace GettingStarted.Data -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public class SampleDbContext : DbContext - { - public DbSet Books { get; set; } - - public SampleDbContext(DbContextOptions options) - : base(options) - { - } +namespace GettingStarted.Data; - protected override void OnModelCreating(ModelBuilder builder) - { - builder.Entity(); - } - } +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public class SampleDbContext(DbContextOptions options) + : DbContext(options) +{ + public DbSet Books => Set(); } diff --git a/src/Examples/GettingStarted/GettingStarted.csproj b/src/Examples/GettingStarted/GettingStarted.csproj index b28d7a0c5a..611aeb37a5 100644 --- a/src/Examples/GettingStarted/GettingStarted.csproj +++ b/src/Examples/GettingStarted/GettingStarted.csproj @@ -1,13 +1,17 @@ - $(NetCoreAppVersion) + net9.0;net8.0 + + + - + diff --git a/src/Examples/GettingStarted/Models/Book.cs b/src/Examples/GettingStarted/Models/Book.cs index 9f15d3e3c9..66beed1072 100644 --- a/src/Examples/GettingStarted/Models/Book.cs +++ b/src/Examples/GettingStarted/Models/Book.cs @@ -2,18 +2,18 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace GettingStarted.Models +namespace GettingStarted.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class Book : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Book : Identifiable - { - [Attr] - public string Title { get; set; } + [Attr] + public string Title { get; set; } = null!; - [Attr] - public int PublishYear { get; set; } + [Attr] + public int PublishYear { get; set; } - [HasOne] - public Person Author { get; set; } - } + [HasOne] + public Person Author { get; set; } = null!; } diff --git a/src/Examples/GettingStarted/Models/Person.cs b/src/Examples/GettingStarted/Models/Person.cs index 495a4fe27b..89ca4c5a69 100644 --- a/src/Examples/GettingStarted/Models/Person.cs +++ b/src/Examples/GettingStarted/Models/Person.cs @@ -1,17 +1,16 @@ -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace GettingStarted.Models +namespace GettingStarted.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class Person : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Person : Identifiable - { - [Attr] - public string Name { get; set; } + [Attr] + public string Name { get; set; } = null!; - [HasMany] - public ICollection Books { get; set; } - } + [HasMany] + public ICollection Books { get; set; } = new List(); } diff --git a/src/Examples/GettingStarted/Program.cs b/src/Examples/GettingStarted/Program.cs index 68bca0ae86..634e130a3f 100644 --- a/src/Examples/GettingStarted/Program.cs +++ b/src/Examples/GettingStarted/Program.cs @@ -1,21 +1,93 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Hosting; +using System.Diagnostics; +using GettingStarted.Data; +using GettingStarted.Models; +using JsonApiDotNetCore.Configuration; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; -namespace GettingStarted +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +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(); + +// Configure the HTTP request pipeline. + +app.UseRouting(); +app.UseJsonApi(); +app.MapControllers(); + +await CreateDatabaseAsync(app.Services); + +await app.RunAsync(); + +[Conditional("DEBUG")] +static void SetDbContextDebugOptions(DbContextOptionsBuilder options) { - internal static class Program + 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(); + await dbContext.Database.EnsureDeletedAsync(); + await dbContext.Database.EnsureCreatedAsync(); + + await CreateSampleDataAsync(dbContext); +} + +static async Task CreateSampleDataAsync(SampleDbContext dbContext) +{ + // Note: The generate-examples.ps1 script (to create example requests in documentation) depends on these. + + dbContext.Books.AddRange(new Book { - public static void Main(string[] args) + Title = "Frankenstein", + PublishYear = 1818, + Author = new Person { - CreateHostBuilder(args).Build().Run(); + Name = "Mary Shelley" } - - private static IHostBuilder CreateHostBuilder(string[] args) + }, new Book + { + Title = "Robinson Crusoe", + PublishYear = 1719, + Author = new Person + { + Name = "Daniel Defoe" + } + }, new Book + { + Title = "Gulliver's Travels", + PublishYear = 1726, + Author = new Person { - return Host.CreateDefaultBuilder(args).ConfigureWebHostDefaults(webBuilder => - { - webBuilder.UseStartup(); - }); + Name = "Jonathan Swift" } - } + }); + + await dbContext.SaveChangesAsync(); } diff --git a/src/Examples/GettingStarted/Properties/launchSettings.json b/src/Examples/GettingStarted/Properties/launchSettings.json index b68c2481ed..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, @@ -10,16 +10,16 @@ "profiles": { "IIS Express": { "commandName": "IISExpress", - "launchBrowser": false, - "launchUrl": "api/people", + "launchBrowser": true, + "launchUrl": "api/people?include=books", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } }, "Kestrel": { "commandName": "Project", - "launchBrowser": false, - "launchUrl": "api/people", + "launchBrowser": true, + "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 571311caf8..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/Startup.cs b/src/Examples/GettingStarted/Startup.cs deleted file mode 100644 index b942ecc98d..0000000000 --- a/src/Examples/GettingStarted/Startup.cs +++ /dev/null @@ -1,74 +0,0 @@ -using GettingStarted.Data; -using GettingStarted.Models; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using Microsoft.AspNetCore.Builder; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Newtonsoft.Json; - -namespace GettingStarted -{ - public sealed class Startup - { - // This method gets called by the runtime. Use this method to add services to the container. - public void ConfigureServices(IServiceCollection services) - { - services.AddDbContext(options => options.UseSqlite("Data Source=sample.db")); - - services.AddJsonApi(options => - { - options.Namespace = "api"; - options.UseRelativeLinks = true; - options.IncludeTotalResourceCount = true; - options.SerializerSettings.Formatting = Formatting.Indented; - }); - } - - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - [UsedImplicitly] - public void Configure(IApplicationBuilder app, SampleDbContext context) - { - context.Database.EnsureDeleted(); - context.Database.EnsureCreated(); - CreateSampleData(context); - - app.UseRouting(); - app.UseJsonApi(); - app.UseEndpoints(endpoints => endpoints.MapControllers()); - } - - private static void CreateSampleData(SampleDbContext context) - { - // Note: The generate-examples.ps1 script (to create example requests in documentation) depends on these. - - context.Books.AddRange(new Book - { - Title = "Frankenstein", - PublishYear = 1818, - Author = new Person - { - Name = "Mary Shelley" - } - }, new Book - { - Title = "Robinson Crusoe", - PublishYear = 1719, - Author = new Person - { - Name = "Daniel Defoe" - } - }, new Book - { - Title = "Gulliver's Travels", - PublishYear = 1726, - Author = new Person - { - Name = "Jonathan Swift" - } - }); - - context.SaveChanges(); - } - } -} 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 49708c5465..aa51110869 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/NonJsonApiController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/NonJsonApiController.cs @@ -1,55 +1,50 @@ -using System.IO; -using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; -namespace JsonApiDotNetCoreExample.Controllers +namespace JsonApiDotNetCoreExample.Controllers; + +[Route("[controller]")] +public sealed class NonJsonApiController : ControllerBase { - [Route("[controller]")] - public sealed class NonJsonApiController : ControllerBase + [HttpGet] + public IActionResult Get() { - [HttpGet] - public IActionResult Get() - { - string[] result = - { - "Welcome!" - }; - - return Ok(result); - } - - [HttpPost] - public async Task PostAsync() - { - string name = await new StreamReader(Request.Body).ReadToEndAsync(); + string[] result = ["Welcome!"]; - if (string.IsNullOrEmpty(name)) - { - return BadRequest("Please send your name."); - } + return Ok(result); + } - string result = "Hello, " + name; - return Ok(result); - } + [HttpPost] + public async Task PostAsync() + { + using var reader = new StreamReader(Request.Body, leaveOpen: true); + string name = await reader.ReadToEndAsync(); - [HttpPut] - public IActionResult Put([FromBody] string name) + if (string.IsNullOrEmpty(name)) { - string result = "Hi, " + name; - return Ok(result); + return BadRequest("Please send your name."); } - [HttpPatch] - public IActionResult Patch(string name) - { - string result = "Good day, " + name; - return Ok(result); - } + string result = $"Hello, {name}"; + return Ok(result); + } - [HttpDelete] - public IActionResult Delete() - { - return Ok("Bye."); - } + [HttpPut] + public IActionResult Put([FromBody] string name) + { + string result = $"Hi, {name}"; + return Ok(result); + } + + [HttpPatch] + public IActionResult Patch(string name) + { + string result = $"Good day, {name}"; + return Ok(result); + } + + [HttpDelete] + public IActionResult Delete() + { + return Ok("Bye."); } } diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/OperationsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/OperationsController.cs index 4851336a9a..a5cb2ef2e3 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/OperationsController.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Controllers/OperationsController.cs @@ -3,16 +3,10 @@ using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources; -using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCoreExample.Controllers -{ - public sealed class OperationsController : JsonApiOperationsController - { - public OperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request, - ITargetedFields targetedFields) - : base(options, loggerFactory, processor, request, targetedFields) - { - } - } -} +namespace JsonApiDotNetCoreExample.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/JsonApiDotNetCoreExample/Controllers/PeopleController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/PeopleController.cs deleted file mode 100644 index 430790bc6e..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/PeopleController.cs +++ /dev/null @@ -1,16 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using JsonApiDotNetCoreExample.Models; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExample.Controllers -{ - public sealed class PeopleController : JsonApiController - { - public PeopleController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs deleted file mode 100644 index a9536f009b..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TagsController.cs +++ /dev/null @@ -1,16 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using JsonApiDotNetCoreExample.Models; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExample.Controllers -{ - public sealed class TagsController : JsonApiController - { - public TagsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsController.cs b/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsController.cs deleted file mode 100644 index a28a7033d6..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Controllers/TodoItemsController.cs +++ /dev/null @@ -1,16 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using JsonApiDotNetCoreExample.Models; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExample.Controllers -{ - public sealed class TodoItemsController : JsonApiController - { - public TodoItemsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs index 9cb18cf548..f5c7e8e401 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Data/AppDbContext.cs @@ -1,43 +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 +namespace JsonApiDotNetCoreExample.Data; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class AppDbContext(DbContextOptions options) + : DbContext(options) { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class AppDbContext : DbContext + public DbSet TodoItems => Set(); + + protected override void OnModelCreating(ModelBuilder builder) { - public DbSet TodoItems { get; set; } + // When deleting a person, un-assign him/her from existing todo-items. + builder.Entity() + .HasMany(person => person.AssignedTodoItems) + .WithOne(todoItem => todoItem.Assignee); - public AppDbContext(DbContextOptions options) - : base(options) - { - } + // When deleting a person, the todo-items he/she owns are deleted too. + builder.Entity() + .HasMany(person => person.OwnedTodoItems) + .WithOne(todoItem => todoItem.Owner); - protected override void OnModelCreating(ModelBuilder builder) + AdjustDeleteBehaviorForJsonApi(builder); + } + + private static void AdjustDeleteBehaviorForJsonApi(ModelBuilder builder) + { + foreach (IMutableForeignKey foreignKey in builder.Model.GetEntityTypes() + .SelectMany(entityType => entityType.GetForeignKeys())) { - builder.Entity() - .HasKey(todoItemTag => new - { - todoItemTag.TodoItemId, - todoItemTag.TagId - }); - - // When deleting a person, un-assign him/her from existing todo items. - builder.Entity() - .HasMany(person => person.AssignedTodoItems) - .WithOne(todoItem => todoItem.Assignee) - .IsRequired(false) - .OnDelete(DeleteBehavior.SetNull); - - // When deleting a person, the todo items he/she owns are deleted too. - builder.Entity() - .HasOne(todoItem => todoItem.Owner) - .WithMany() - .IsRequired() - .OnDelete(DeleteBehavior.Cascade); + if (foreignKey.DeleteBehavior == DeleteBehavior.ClientSetNull) + { + foreignKey.DeleteBehavior = DeleteBehavior.SetNull; + } + + 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 43b57b98e5..06036968d0 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Definitions/TodoItemDefinition.cs @@ -1,53 +1,43 @@ using System.ComponentModel; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; using JsonApiDotNetCoreExample.Models; -using Microsoft.AspNetCore.Authentication; -namespace JsonApiDotNetCoreExample.Definitions +namespace JsonApiDotNetCoreExample.Definitions; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class TodoItemDefinition(IResourceGraph resourceGraph, TimeProvider timeProvider) + : JsonApiResourceDefinition(resourceGraph) { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class TodoItemDefinition : JsonApiResourceDefinition + private readonly TimeProvider _timeProvider = timeProvider; + + public override SortExpression OnApplySort(SortExpression? existingSort) { - private readonly ISystemClock _systemClock; + return existingSort ?? GetDefaultSortOrder(); + } - public TodoItemDefinition(IResourceGraph resourceGraph, ISystemClock systemClock) - : base(resourceGraph) - { - _systemClock = systemClock; - } + private SortExpression GetDefaultSortOrder() + { + return CreateSortExpressionFromLambda([ + (todoItem => todoItem.Priority, ListSortDirection.Ascending), + (todoItem => todoItem.LastModifiedAt, ListSortDirection.Descending) + ]); + } - public override SortExpression OnApplySort(SortExpression existingSort) + public override Task OnWritingAsync(TodoItem resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (writeOperation == WriteOperationKind.CreateResource) { - return existingSort ?? GetDefaultSortOrder(); + resource.CreatedAt = _timeProvider.GetUtcNow(); } - - private SortExpression GetDefaultSortOrder() + else if (writeOperation == WriteOperationKind.UpdateResource) { - return CreateSortExpressionFromLambda(new PropertySortOrder - { - (todoItem => todoItem.Priority, ListSortDirection.Descending), - (todoItem => todoItem.LastModifiedAt, ListSortDirection.Descending) - }); + resource.LastModifiedAt = _timeProvider.GetUtcNow(); } - public override Task OnWritingAsync(TodoItem resource, OperationKind operationKind, CancellationToken cancellationToken) - { - if (operationKind == OperationKind.CreateResource) - { - resource.CreatedAt = _systemClock.UtcNow; - } - else if (operationKind == OperationKind.UpdateResource) - { - resource.LastModifiedAt = _systemClock.UtcNow; - } - - return Task.CompletedTask; - } + 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 95c1faf884..768a2de827 100644 --- a/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj +++ b/src/Examples/JsonApiDotNetCoreExample/JsonApiDotNetCoreExample.csproj @@ -1,14 +1,25 @@ - $(NetCoreAppVersion) + net9.0;net8.0 + true + GeneratedSwagger + + + + - - + + + + + + diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs index 0f30ab3bf6..d11fbffff6 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Person.cs @@ -1,20 +1,27 @@ -using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreExample.Models +namespace JsonApiDotNetCoreExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class Person : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Person : Identifiable - { - [Attr] - public string FirstName { get; set; } + [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; - [Attr] - public string LastName { get; set; } + [HasMany] + public ISet OwnedTodoItems { get; set; } = new HashSet(); - [HasMany] - public ISet AssignedTodoItems { get; set; } - } + [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 cd6e73552f..8904ec01a3 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/Tag.cs @@ -3,14 +3,16 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreExample.Models +namespace JsonApiDotNetCoreExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class Tag : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Tag : Identifiable - { - [Required] - [MinLength(1)] - [Attr] - public string Name { get; set; } - } + [Attr] + [MinLength(1)] + public string Name { get; set; } = null!; + + [HasMany] + public ISet TodoItems { get; set; } = new HashSet(); } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs index 95643e61a3..68df7cef27 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItem.cs @@ -1,37 +1,36 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations.Schema; +using System.ComponentModel.DataAnnotations; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCoreExample.Models +namespace JsonApiDotNetCoreExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class TodoItem : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class TodoItem : Identifiable - { - [Attr] - public string Description { get; set; } + [Attr] + public string Description { get; set; } = null!; - [Attr] - public TodoItemPriority Priority { get; set; } + [Attr] + [Required] + public TodoItemPriority? Priority { get; set; } - [Attr(Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort | AttrCapabilities.AllowView)] - public DateTimeOffset CreatedAt { get; set; } + [Attr] + public long? DurationInHours { get; set; } - [Attr(PublicName = "modifiedAt", Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort | AttrCapabilities.AllowView)] - public DateTimeOffset? LastModifiedAt { get; set; } + [Attr(Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort | AttrCapabilities.AllowView)] + public DateTimeOffset CreatedAt { get; set; } - [HasOne] - public Person Owner { get; set; } + [Attr(PublicName = "modifiedAt", Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort | AttrCapabilities.AllowView)] + public DateTimeOffset? LastModifiedAt { get; set; } - [HasOne] - public Person Assignee { get; set; } + [HasOne] + public Person Owner { get; set; } = null!; - [NotMapped] - [HasManyThrough(nameof(TodoItemTags))] - public ISet Tags { get; set; } + [HasOne] + public Person? Assignee { get; set; } - public ISet TodoItemTags { get; set; } - } + [HasMany] + public ISet Tags { get; set; } = new HashSet(); } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemPriority.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemPriority.cs index 5f8687a064..84e3567b31 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemPriority.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemPriority.cs @@ -1,12 +1,11 @@ using JetBrains.Annotations; -namespace JsonApiDotNetCoreExample.Models +namespace JsonApiDotNetCoreExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public enum TodoItemPriority { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public enum TodoItemPriority - { - Low, - Medium, - High - } + High = 1, + Medium = 2, + Low = 3 } diff --git a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemTag.cs b/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemTag.cs deleted file mode 100644 index 8d1ef42d42..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Models/TodoItemTag.cs +++ /dev/null @@ -1,14 +0,0 @@ -using JetBrains.Annotations; - -namespace JsonApiDotNetCoreExample.Models -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class TodoItemTag - { - public int TodoItemId { get; set; } - public TodoItem TodoItem { get; set; } - - public int TagId { get; set; } - public Tag Tag { get; set; } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Program.cs b/src/Examples/JsonApiDotNetCoreExample/Program.cs index 4c97d8a7f4..56448b271e 100644 --- a/src/Examples/JsonApiDotNetCoreExample/Program.cs +++ b/src/Examples/JsonApiDotNetCoreExample/Program.cs @@ -1,22 +1,128 @@ -using JsonApiDotNetCoreExample.Startups; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Hosting; +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.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Scalar.AspNetCore; -namespace JsonApiDotNetCoreExample +[assembly: ExcludeFromCodeCoverage] + +WebApplication app = CreateWebApplication(args); + +if (!IsGeneratingOpenApiDocumentAtBuildTime()) +{ + await CreateDatabaseAsync(app.Services); +} + +await app.RunAsync(); + +static WebApplication CreateWebApplication(string[] args) { - internal static class Program + using ICodeTimerSession codeTimerSession = new DefaultCodeTimerSession(); + CodeTimingSessionManager.Capture(codeTimerSession); + + WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + + // Add services to the container. + ConfigureServices(builder); + + WebApplication app = builder.Build(); + + // Configure the HTTP request pipeline. + ConfigurePipeline(app); + + if (CodeTimingSessionManager.IsEnabled && app.Logger.IsEnabled(LogLevel.Information)) { - public static void Main(string[] args) - { - CreateHostBuilder(args).Build().Run(); - } + string timingResults = CodeTimingSessionManager.Current.GetResults(); + AppLog.LogStartupTimings(app.Logger, Environment.NewLine, timingResults); + } + + return app; +} + +static void ConfigureServices(WebApplicationBuilder builder) +{ + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Configure services"); + + builder.Services.TryAddSingleton(TimeProvider.System); - private static IHostBuilder CreateHostBuilder(string[] args) + builder.Services.AddDbContext(options => + { + string? connectionString = builder.Configuration.GetConnectionString("Default"); + options.UseNpgsql(connectionString); + + SetDbContextDebugOptions(options); + }); + + using (CodeTimingSessionManager.Current.Measure("AddJsonApi()")) + { + builder.Services.AddJsonApi(options => { - return Host.CreateDefaultBuilder(args).ConfigureWebHostDefaults(webBuilder => - { - webBuilder.UseStartup(); - }); - } + options.Namespace = "api"; + options.UseRelativeLinks = true; + options.IncludeTotalResourceCount = 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()); + } +} + +[Conditional("DEBUG")] +static void SetDbContextDebugOptions(DbContextOptionsBuilder options) +{ + options.EnableDetailedErrors(); + options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(builder => builder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); +} + +static void ConfigurePipeline(WebApplication app) +{ + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Configure pipeline"); + + app.UseRouting(); + + using (CodeTimingSessionManager.Current.Measure("UseJsonApi()")) + { + app.UseJsonApi(); + } + + 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) +{ + 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/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/Startups/EmptyStartup.cs b/src/Examples/JsonApiDotNetCoreExample/Startups/EmptyStartup.cs deleted file mode 100644 index 19879aef27..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Startups/EmptyStartup.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.DependencyInjection; - -namespace JsonApiDotNetCoreExample.Startups -{ - /// - /// Empty startup class, required for integration tests. Changes in ASP.NET Core 3 no longer allow Startup class to be defined in test projects. See - /// https://github.com/aspnet/AspNetCore/issues/15373. - /// - public abstract class EmptyStartup - { - public virtual void ConfigureServices(IServiceCollection services) - { - } - - public virtual void Configure(IApplicationBuilder app, IWebHostEnvironment environment) - { - } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs b/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs deleted file mode 100644 index 84ddf4a4de..0000000000 --- a/src/Examples/JsonApiDotNetCoreExample/Startups/Startup.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCoreExample.Data; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; - -namespace JsonApiDotNetCoreExample.Startups -{ - public sealed class Startup : EmptyStartup - { - private readonly string _connectionString; - - public Startup(IConfiguration configuration) - { - string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - _connectionString = configuration["Data:DefaultConnection"].Replace("###", postgresPassword); - } - - // This method gets called by the runtime. Use this method to add services to the container. - public override void ConfigureServices(IServiceCollection services) - { - services.AddSingleton(); - - services.AddDbContext(options => - { - options.UseNpgsql(_connectionString); -#if DEBUG - options.EnableSensitiveDataLogging(); - options.EnableDetailedErrors(); -#endif - }); - - services.AddJsonApi(options => - { - options.Namespace = "api/v1"; - options.UseRelativeLinks = true; - options.ValidateModelState = true; - options.IncludeTotalResourceCount = true; - options.SerializerSettings.Formatting = Formatting.Indented; - options.SerializerSettings.Converters.Add(new StringEnumConverter()); -#if DEBUG - options.IncludeExceptionStackTraceInErrors = true; -#endif - }, discovery => discovery.AddCurrentAssembly()); - } - - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - public override void Configure(IApplicationBuilder app, IWebHostEnvironment environment) - { - using (IServiceScope scope = app.ApplicationServices.CreateScope()) - { - var appDbContext = scope.ServiceProvider.GetRequiredService(); - appDbContext.Database.EnsureCreated(); - } - - app.UseRouting(); - app.UseJsonApi(); - app.UseEndpoints(endpoints => endpoints.MapControllers()); - } - } -} diff --git a/src/Examples/JsonApiDotNetCoreExample/appsettings.json b/src/Examples/JsonApiDotNetCoreExample/appsettings.json index 5d13a80bef..418fcb7812 100644 --- a/src/Examples/JsonApiDotNetCoreExample/appsettings.json +++ b/src/Examples/JsonApiDotNetCoreExample/appsettings.json @@ -1,13 +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" + // 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/Controllers/ResourceAsController.cs b/src/Examples/MultiDbContextExample/Controllers/ResourceAsController.cs deleted file mode 100644 index 4e976acdc0..0000000000 --- a/src/Examples/MultiDbContextExample/Controllers/ResourceAsController.cs +++ /dev/null @@ -1,16 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; -using MultiDbContextExample.Models; - -namespace MultiDbContextExample.Controllers -{ - public sealed class ResourceAsController : JsonApiController - { - public ResourceAsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/src/Examples/MultiDbContextExample/Controllers/ResourceBsController.cs b/src/Examples/MultiDbContextExample/Controllers/ResourceBsController.cs deleted file mode 100644 index bd61b7aa2e..0000000000 --- a/src/Examples/MultiDbContextExample/Controllers/ResourceBsController.cs +++ /dev/null @@ -1,16 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; -using MultiDbContextExample.Models; - -namespace MultiDbContextExample.Controllers -{ - public sealed class ResourceBsController : JsonApiController - { - public ResourceBsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/src/Examples/MultiDbContextExample/Data/DbContextA.cs b/src/Examples/MultiDbContextExample/Data/DbContextA.cs index cb6000e051..32f6197600 100644 --- a/src/Examples/MultiDbContextExample/Data/DbContextA.cs +++ b/src/Examples/MultiDbContextExample/Data/DbContextA.cs @@ -2,16 +2,11 @@ using Microsoft.EntityFrameworkCore; using MultiDbContextExample.Models; -namespace MultiDbContextExample.Data -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class DbContextA : DbContext - { - public DbSet ResourceAs { get; set; } +namespace MultiDbContextExample.Data; - public DbContextA(DbContextOptions options) - : base(options) - { - } - } +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class DbContextA(DbContextOptions options) + : DbContext(options) +{ + public DbSet ResourceAs => Set(); } diff --git a/src/Examples/MultiDbContextExample/Data/DbContextB.cs b/src/Examples/MultiDbContextExample/Data/DbContextB.cs index b3e4e6e47f..8759e28e91 100644 --- a/src/Examples/MultiDbContextExample/Data/DbContextB.cs +++ b/src/Examples/MultiDbContextExample/Data/DbContextB.cs @@ -2,16 +2,11 @@ using Microsoft.EntityFrameworkCore; using MultiDbContextExample.Models; -namespace MultiDbContextExample.Data -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class DbContextB : DbContext - { - public DbSet ResourceBs { get; set; } +namespace MultiDbContextExample.Data; - public DbContextB(DbContextOptions options) - : base(options) - { - } - } +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class DbContextB(DbContextOptions options) + : DbContext(options) +{ + public DbSet ResourceBs => Set(); } diff --git a/src/Examples/MultiDbContextExample/Models/ResourceA.cs b/src/Examples/MultiDbContextExample/Models/ResourceA.cs index 85cbf2b89a..44a313a32f 100644 --- a/src/Examples/MultiDbContextExample/Models/ResourceA.cs +++ b/src/Examples/MultiDbContextExample/Models/ResourceA.cs @@ -2,12 +2,12 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace MultiDbContextExample.Models +namespace MultiDbContextExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class ResourceA : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class ResourceA : Identifiable - { - [Attr] - public string NameA { get; set; } - } + [Attr] + public string? NameA { get; set; } } diff --git a/src/Examples/MultiDbContextExample/Models/ResourceB.cs b/src/Examples/MultiDbContextExample/Models/ResourceB.cs index dd1739ee49..3a6bc7316e 100644 --- a/src/Examples/MultiDbContextExample/Models/ResourceB.cs +++ b/src/Examples/MultiDbContextExample/Models/ResourceB.cs @@ -2,12 +2,12 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace MultiDbContextExample.Models +namespace MultiDbContextExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class ResourceB : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class ResourceB : Identifiable - { - [Attr] - public string NameB { get; set; } - } + [Attr] + public string? NameB { get; set; } } diff --git a/src/Examples/MultiDbContextExample/MultiDbContextExample.csproj b/src/Examples/MultiDbContextExample/MultiDbContextExample.csproj index b28d7a0c5a..611aeb37a5 100644 --- a/src/Examples/MultiDbContextExample/MultiDbContextExample.csproj +++ b/src/Examples/MultiDbContextExample/MultiDbContextExample.csproj @@ -1,13 +1,17 @@ - $(NetCoreAppVersion) + net9.0;net8.0 + + + - + diff --git a/src/Examples/MultiDbContextExample/Program.cs b/src/Examples/MultiDbContextExample/Program.cs index d5800d95e8..481e8f7118 100644 --- a/src/Examples/MultiDbContextExample/Program.cs +++ b/src/Examples/MultiDbContextExample/Program.cs @@ -1,18 +1,100 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Hosting; +using System.Diagnostics; +using JsonApiDotNetCore.Configuration; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using MultiDbContextExample.Data; +using MultiDbContextExample.Models; +using MultiDbContextExample.Repositories; -namespace MultiDbContextExample +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +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) +}); + +WebApplication app = builder.Build(); + +// Configure the HTTP request pipeline. + +app.UseRouting(); +app.UseJsonApi(); +app.MapControllers(); + +await CreateDatabaseAsync(app.Services); + +await app.RunAsync(); + +[Conditional("DEBUG")] +static void SetDbContextDebugOptions(DbContextOptionsBuilder options) { - internal static class Program + options.EnableDetailedErrors(); + options.EnableSensitiveDataLogging(); + options.ConfigureWarnings(builder => builder.Ignore(CoreEventId.SensitiveDataLoggingEnabledWarning)); +} + +static async Task CreateDatabaseAsync(IServiceProvider serviceProvider) +{ + await using AsyncServiceScope scope = serviceProvider.CreateAsyncScope(); + + var dbContextA = scope.ServiceProvider.GetRequiredService(); + await CreateSampleDataAAsync(dbContextA); + + var dbContextB = scope.ServiceProvider.GetRequiredService(); + await CreateSampleDataBAsync(dbContextB); +} + +static async Task CreateSampleDataAAsync(DbContextA dbContextA) +{ + await dbContextA.Database.EnsureDeletedAsync(); + await dbContextA.Database.EnsureCreatedAsync(); + + dbContextA.ResourceAs.Add(new ResourceA + { + NameA = "SampleA" + }); + + await dbContextA.SaveChangesAsync(); +} + +static async Task CreateSampleDataBAsync(DbContextB dbContextB) +{ + await dbContextB.Database.EnsureDeletedAsync(); + await dbContextB.Database.EnsureCreatedAsync(); + + dbContextB.ResourceBs.Add(new ResourceB { - public static void Main(string[] args) - { - CreateHostBuilder(args).Build().Run(); - } - - private static IHostBuilder CreateHostBuilder(string[] args) - { - return Host.CreateDefaultBuilder(args).ConfigureWebHostDefaults(webBuilder => webBuilder.UseStartup()); - } - } + NameB = "SampleB" + }); + + await dbContextB.SaveChangesAsync(); } diff --git a/src/Examples/MultiDbContextExample/Properties/launchSettings.json b/src/Examples/MultiDbContextExample/Properties/launchSettings.json index 6d7e1b5cbd..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, @@ -11,16 +11,16 @@ "profiles": { "IIS Express": { "commandName": "IISExpress", - "launchBrowser": false, - "launchUrl": "/resourceBs", + "launchBrowser": true, + "launchUrl": "api/resourceBs", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } }, "Kestrel": { "commandName": "Project", - "launchBrowser": false, - "launchUrl": "/resourceBs", + "launchBrowser": true, + "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 13fb0bbe48..d90b572004 100644 --- a/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs +++ b/src/Examples/MultiDbContextExample/Repositories/DbContextARepository.cs @@ -1,22 +1,16 @@ -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; -using Microsoft.Extensions.Logging; using MultiDbContextExample.Data; -namespace MultiDbContextExample.Repositories -{ - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class DbContextARepository : EntityFrameworkCoreRepository - where TResource : class, IIdentifiable - { - public DbContextARepository(ITargetedFields targetedFields, DbContextResolver contextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory) - { - } - } -} +namespace MultiDbContextExample.Repositories; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +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 0b65caa945..ed56237d56 100644 --- a/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs +++ b/src/Examples/MultiDbContextExample/Repositories/DbContextBRepository.cs @@ -1,22 +1,16 @@ -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; -using Microsoft.Extensions.Logging; using MultiDbContextExample.Data; -namespace MultiDbContextExample.Repositories -{ - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class DbContextBRepository : EntityFrameworkCoreRepository - where TResource : class, IIdentifiable - { - public DbContextBRepository(ITargetedFields targetedFields, DbContextResolver contextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory) - { - } - } -} +namespace MultiDbContextExample.Repositories; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +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/Startup.cs b/src/Examples/MultiDbContextExample/Startup.cs deleted file mode 100644 index bc76c3cd9a..0000000000 --- a/src/Examples/MultiDbContextExample/Startup.cs +++ /dev/null @@ -1,78 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using MultiDbContextExample.Data; -using MultiDbContextExample.Models; -using MultiDbContextExample.Repositories; - -namespace MultiDbContextExample -{ - public sealed class Startup - { - // This method gets called by the runtime. Use this method to add services to the container. - public void ConfigureServices(IServiceCollection services) - { - services.AddDbContext(options => options.UseSqlite("Data Source=A.db")); - services.AddDbContext(options => options.UseSqlite("Data Source=B.db")); - - services.AddResourceRepository>(); - services.AddResourceRepository>(); - - services.AddJsonApi(options => - { - options.IncludeExceptionStackTraceInErrors = true; - }, dbContextTypes: new[] - { - typeof(DbContextA), - typeof(DbContextB) - }); - } - - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - [UsedImplicitly] - public void Configure(IApplicationBuilder app, IWebHostEnvironment env, DbContextA dbContextA, DbContextB dbContextB) - { - if (env.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - } - - EnsureSampleDataA(dbContextA); - EnsureSampleDataB(dbContextB); - - app.UseRouting(); - app.UseJsonApi(); - app.UseEndpoints(endpoints => endpoints.MapControllers()); - } - - private static void EnsureSampleDataA(DbContextA dbContextA) - { - dbContextA.Database.EnsureDeleted(); - dbContextA.Database.EnsureCreated(); - - dbContextA.ResourceAs.Add(new ResourceA - { - NameA = "SampleA" - }); - - dbContextA.SaveChanges(); - } - - private static void EnsureSampleDataB(DbContextB dbContextB) - { - dbContextB.Database.EnsureDeleted(); - dbContextB.Database.EnsureCreated(); - - dbContextB.ResourceBs.Add(new ResourceB - { - NameB = "SampleB" - }); - - dbContextB.SaveChanges(); - } - } -} 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/Controllers/WorkItemsController.cs b/src/Examples/NoEntityFrameworkExample/Controllers/WorkItemsController.cs deleted file mode 100644 index 63ab620b93..0000000000 --- a/src/Examples/NoEntityFrameworkExample/Controllers/WorkItemsController.cs +++ /dev/null @@ -1,16 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; -using NoEntityFrameworkExample.Models; - -namespace NoEntityFrameworkExample.Controllers -{ - public sealed class WorkItemsController : JsonApiController - { - public WorkItemsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/src/Examples/NoEntityFrameworkExample/Data/AppDbContext.cs b/src/Examples/NoEntityFrameworkExample/Data/AppDbContext.cs deleted file mode 100644 index 336951eec3..0000000000 --- a/src/Examples/NoEntityFrameworkExample/Data/AppDbContext.cs +++ /dev/null @@ -1,17 +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 { get; 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 20d381a2ba..0000000000 --- a/src/Examples/NoEntityFrameworkExample/Models/WorkItem.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace NoEntityFrameworkExample.Models -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class WorkItem : Identifiable - { - [Attr] - public bool IsBlocked { get; set; } - - [Attr] - public string Title { get; set; } - - [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 f7c5aa19ec..15a485c08f 100644 --- a/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj +++ b/src/Examples/NoEntityFrameworkExample/NoEntityFrameworkExample.csproj @@ -1,15 +1,17 @@ - $(NetCoreAppVersion) + 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 6653408dc7..8eff35d7a9 100755 --- a/src/Examples/NoEntityFrameworkExample/Program.cs +++ b/src/Examples/NoEntityFrameworkExample/Program.cs @@ -1,21 +1,38 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Hosting; +using JsonApiDotNetCore.Configuration; +using NoEntityFrameworkExample; +using NoEntityFrameworkExample.Data; -namespace NoEntityFrameworkExample +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.AddScoped(); + +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 => { - internal static class Program - { - public static void Main(string[] args) - { - CreateHostBuilder(args).Build().Run(); - } - - private static IHostBuilder CreateHostBuilder(string[] args) - { - return Host.CreateDefaultBuilder(args).ConfigureWebHostDefaults(webBuilder => - { - webBuilder.UseStartup(); - }); - } - } -} + var resourceGraph = serviceProvider.GetRequiredService(); + return resourceGraph.ToEntityModel(); +}); + +WebApplication app = builder.Build(); + +// Configure the HTTP request pipeline. + +app.UseRouting(); +app.UseJsonApi(); +app.MapControllers(); + +await app.RunAsync(); diff --git a/src/Examples/NoEntityFrameworkExample/Properties/launchSettings.json b/src/Examples/NoEntityFrameworkExample/Properties/launchSettings.json index 32bc82dfc2..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, @@ -11,16 +11,16 @@ "profiles": { "IIS Express": { "commandName": "IISExpress", - "launchBrowser": false, - "launchUrl": "/api/reports", + "launchBrowser": true, + "launchUrl": "api/todoItems?include=owner,assignee,tags&filter=equals(priority,'High')", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } }, "Kestrel": { "commandName": "Project", - "launchBrowser": false, - "launchUrl": "/api/reports", + "launchBrowser": true, + "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 f934e7dc9b..0000000000 --- a/src/Examples/NoEntityFrameworkExample/Services/WorkItemService.cs +++ /dev/null @@ -1,117 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Data; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Dapper; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Configuration; -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 primaryId, string relationshipName, ISet secondaryResourceIds, - CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task UpdateAsync(int id, WorkItem resource, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task SetRelationshipAsync(int primaryId, string relationshipName, object secondaryResourceIds, 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 primaryId, string relationshipName, ISet secondaryResourceIds, - 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/Startup.cs b/src/Examples/NoEntityFrameworkExample/Startup.cs deleted file mode 100644 index c51985f5f2..0000000000 --- a/src/Examples/NoEntityFrameworkExample/Startup.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Services; -using Microsoft.AspNetCore.Builder; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using NoEntityFrameworkExample.Data; -using NoEntityFrameworkExample.Models; -using NoEntityFrameworkExample.Services; - -namespace NoEntityFrameworkExample -{ - public sealed class Startup - { - private readonly string _connectionString; - - public Startup(IConfiguration configuration) - { - string postgresPassword = Environment.GetEnvironmentVariable("PGPASSWORD") ?? "postgres"; - _connectionString = configuration["Data:DefaultConnection"].Replace("###", postgresPassword); - } - - // This method gets called by the runtime. Use this method to add services to the container. - public void ConfigureServices(IServiceCollection services) - { - services.AddJsonApi(options => options.Namespace = "api/v1", resources: builder => builder.Add("workItems")); - - services.AddScoped, WorkItemService>(); - - services.AddDbContext(options => options.UseNpgsql(_connectionString)); - } - - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - [UsedImplicitly] - public void Configure(IApplicationBuilder app, AppDbContext context) - { - context.Database.EnsureCreated(); - - app.UseRouting(); - app.UseJsonApi(); - app.UseEndpoints(endpoints => endpoints.MapControllers()); - } - } -} 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/Controllers/ReportsController.cs b/src/Examples/ReportsExample/Controllers/ReportsController.cs deleted file mode 100644 index c154dc5562..0000000000 --- a/src/Examples/ReportsExample/Controllers/ReportsController.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using ReportsExample.Models; - -namespace ReportsExample.Controllers -{ - [Route("api/[controller]")] - public class ReportsController : BaseJsonApiController - { - public ReportsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IGetAllService getAll) - : base(options, loggerFactory, getAll) - { - } - - [HttpGet] - public override async Task GetAsync(CancellationToken cancellationToken) - { - return await base.GetAsync(cancellationToken); - } - } -} diff --git a/src/Examples/ReportsExample/Models/Report.cs b/src/Examples/ReportsExample/Models/Report.cs index 6635687a1d..5344025d2e 100644 --- a/src/Examples/ReportsExample/Models/Report.cs +++ b/src/Examples/ReportsExample/Models/Report.cs @@ -1,16 +1,17 @@ using JetBrains.Annotations; +using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace ReportsExample.Models +namespace ReportsExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource(GenerateControllerEndpoints = JsonApiEndpoints.GetCollection)] +public sealed class Report : Identifiable { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Report : Identifiable - { - [Attr] - public string Title { get; set; } + [Attr] + public string Title { get; set; } = null!; - [Attr] - public ReportStatistics Statistics { get; set; } - } + [Attr] + public ReportStatistics Statistics { get; set; } = null!; } diff --git a/src/Examples/ReportsExample/Models/ReportStatistics.cs b/src/Examples/ReportsExample/Models/ReportStatistics.cs index 53c2c2d2ee..2df01f9b5e 100644 --- a/src/Examples/ReportsExample/Models/ReportStatistics.cs +++ b/src/Examples/ReportsExample/Models/ReportStatistics.cs @@ -1,11 +1,10 @@ using JetBrains.Annotations; -namespace ReportsExample.Models +namespace ReportsExample.Models; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +public sealed class ReportStatistics { - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class ReportStatistics - { - public string ProgressIndication { get; set; } - public int HoursSpent { get; set; } - } + public string ProgressIndication { get; set; } = null!; + public int HoursSpent { get; set; } } diff --git a/src/Examples/ReportsExample/Program.cs b/src/Examples/ReportsExample/Program.cs index 6356b0f52a..7f89ad9301 100644 --- a/src/Examples/ReportsExample/Program.cs +++ b/src/Examples/ReportsExample/Program.cs @@ -1,21 +1,27 @@ -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Hosting; +using JsonApiDotNetCore.Configuration; -namespace ReportsExample +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +// Add services to the container. + +builder.Services.AddJsonApi(options => { - internal static class Program - { - public static void Main(string[] args) - { - CreateHostBuilder(args).Build().Run(); - } - - private static IHostBuilder CreateHostBuilder(string[] args) - { - return Host.CreateDefaultBuilder(args).ConfigureWebHostDefaults(webBuilder => - { - webBuilder.UseStartup(); - }); - } - } -} + 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(); + +// Configure the HTTP request pipeline. + +app.UseRouting(); +app.UseJsonApi(); +app.MapControllers(); + +await app.RunAsync(); diff --git a/src/Examples/ReportsExample/Properties/launchSettings.json b/src/Examples/ReportsExample/Properties/launchSettings.json index ee2eba1f80..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, @@ -11,16 +11,16 @@ "profiles": { "IIS Express": { "commandName": "IISExpress", - "launchBrowser": false, - "launchUrl": "/api/reports", + "launchBrowser": true, + "launchUrl": "api/reports", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } }, "Kestrel": { "commandName": "Project", - "launchBrowser": false, - "launchUrl": "/api/reports", + "launchBrowser": true, + "launchUrl": "api/reports", "applicationUrl": "https://localhost:44348;http://localhost:14148", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" diff --git a/src/Examples/ReportsExample/ReportsExample.csproj b/src/Examples/ReportsExample/ReportsExample.csproj index 95c1faf884..6ade1386be 100644 --- a/src/Examples/ReportsExample/ReportsExample.csproj +++ b/src/Examples/ReportsExample/ReportsExample.csproj @@ -1,14 +1,13 @@ - $(NetCoreAppVersion) + net9.0;net8.0 - - - + - - + + diff --git a/src/Examples/ReportsExample/Services/ReportService.cs b/src/Examples/ReportsExample/Services/ReportService.cs index cd9447bd3f..62bb7c9554 100644 --- a/src/Examples/ReportsExample/Services/ReportService.cs +++ b/src/Examples/ReportsExample/Services/ReportService.cs @@ -1,46 +1,32 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; using ReportsExample.Models; -namespace ReportsExample.Services +namespace ReportsExample.Services; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public class ReportService : IGetAllService { - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public class ReportService : IGetAllService + public Task> GetAsync(CancellationToken cancellationToken) { - private readonly ILogger _logger; - - public ReportService(ILoggerFactory loggerFactory) - { - _logger = loggerFactory.CreateLogger(); - } - - public Task> GetAsync(CancellationToken cancellationToken) - { - _logger.LogInformation("GetAsync"); - - IReadOnlyCollection reports = GetReports(); - - return Task.FromResult(reports); - } + IReadOnlyCollection reports = GetReports().AsReadOnly(); + return Task.FromResult(reports); + } - private IReadOnlyCollection GetReports() - { - return new List + private List GetReports() + { + return + [ + new Report { - new Report + Id = 1, + Title = "Status Report", + Statistics = new ReportStatistics { - Title = "Status Report", - Statistics = new ReportStatistics - { - ProgressIndication = "Almost done", - HoursSpent = 24 - } + ProgressIndication = "Almost done", + HoursSpent = 24 } - }; - } + } + ]; } } diff --git a/src/Examples/ReportsExample/Startup.cs b/src/Examples/ReportsExample/Startup.cs deleted file mode 100644 index a030441258..0000000000 --- a/src/Examples/ReportsExample/Startup.cs +++ /dev/null @@ -1,23 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.DependencyInjection; - -namespace ReportsExample -{ - public sealed class Startup - { - // This method gets called by the runtime. Use this method to add services to the container. - public void ConfigureServices(IServiceCollection services) - { - services.AddJsonApi(options => options.Namespace = "api", discovery => discovery.AddCurrentAssembly()); - } - - // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. - public void Configure(IApplicationBuilder app) - { - app.UseRouting(); - app.UseJsonApi(); - app.UseEndpoints(endpoints => endpoints.MapControllers()); - } - } -} 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/.editorconfig b/src/JsonApiDotNetCore.Annotations/.editorconfig new file mode 100644 index 0000000000..6e602763d4 --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/.editorconfig @@ -0,0 +1,4 @@ +[*.cs] + +# AV1505: Namespace should match with assembly name +dotnet_diagnostic.AV1505.severity = none diff --git a/src/JsonApiDotNetCore.Annotations/ArgumentGuard.cs b/src/JsonApiDotNetCore.Annotations/ArgumentGuard.cs new file mode 100644 index 0000000000..1eea1c3841 --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/ArgumentGuard.cs @@ -0,0 +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 NotNullNorEmpty([SysNotNull] IEnumerable? value, [CallerArgumentExpression(nameof(value))] string? parameterName = null) + { + ArgumentNullException.ThrowIfNull(value, parameterName); + + if (!value.Any()) + { + throw new ArgumentException("Collection cannot be null or empty.", parameterName); + } + } +} diff --git a/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs b/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs new file mode 100644 index 0000000000..683e34764b --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/CollectionConverter.cs @@ -0,0 +1,147 @@ +using System.Collections; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore; + +internal sealed class CollectionConverter +{ + 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. + /// + /// + /// Source to copy from. + /// + /// + /// Target collection type, for example: ) + /// ]]> or ) + /// ]]>. + /// + public IEnumerable CopyToTypedCollection(IEnumerable source, Type collectionType) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(collectionType); + + Type concreteCollectionType = ToConcreteCollectionType(collectionType); + dynamic concreteCollectionInstance = Activator.CreateInstance(concreteCollectionType)!; + + foreach (object item in source) + { + concreteCollectionInstance.Add((dynamic)item); + } + + return concreteCollectionInstance; + } + + /// + /// Returns a compatible collection type that can be instantiated, for example: -> List
+ /// ]]> or + /// -> HashSet
+ /// ]]>. + ///
+ private Type ToConcreteCollectionType(Type collectionType) + { + if (collectionType is { IsInterface: true, IsGenericType: true }) + { + Type openCollectionType = collectionType.GetGenericTypeDefinition(); + + if (HashSetCompatibleCollectionTypes.Contains(openCollectionType)) + { + return typeof(HashSet<>).MakeGenericType(collectionType.GenericTypeArguments[0]); + } + + if (openCollectionType == typeof(IList<>) || openCollectionType == typeof(IReadOnlyList<>)) + { + return typeof(List<>).MakeGenericType(collectionType.GenericTypeArguments[0]); + } + } + + return collectionType; + } + + /// + /// Returns a collection that contains zero, one or multiple resources, depending on the specified value. + /// + public IReadOnlyCollection ExtractResources(object? value) + { + return value switch + { + 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: -> string + /// ]]> or + /// null + /// ]]>. + /// + public Type? FindCollectionElementType(Type? type) + { + if (type != null) + { + Type? enumerableClosedType = IsEnumerableClosedType(type) ? type : null; + enumerableClosedType ??= type.GetInterfaces().FirstOrDefault(IsEnumerableClosedType); + + if (enumerableClosedType != null) + { + 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: + /// -> false + /// ]]> or -> true + /// ]]>. + /// + public bool TypeCanContainHashSet(Type collectionType) + { + ArgumentNullException.ThrowIfNull(collectionType); + + if (collectionType.IsGenericType) + { + Type openCollectionType = collectionType.GetGenericTypeDefinition(); + return HashSetCompatibleCollectionTypes.Contains(openCollectionType); + } + + return false; + } +} 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 new file mode 100644 index 0000000000..4c0cd133f9 --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Configuration/ResourceType.cs @@ -0,0 +1,404 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Configuration; + +/// +/// Metadata about the shape of a JSON:API resource in the resource graph. +/// +[PublicAPI] +public sealed class ResourceType +{ + 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; + + /// + /// The publicly exposed resource name. + /// + 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. + /// + public Type ClrType { get; } + + /// + /// The CLR type of the resource identity. + /// + public Type IdentityClrType { get; } + + /// + /// The base resource type, in case this is a derived type. + /// + public ResourceType? BaseType { get; internal set; } + + /// + /// The resource types that directly derive from this one. + /// + 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 + /// includes the attributes and relationships from base types. + /// + public IReadOnlyCollection Fields { get; } + + /// + /// Exposed resource attributes. See https://jsonapi.org/format/#document-resource-object-attributes. When using resource inheritance, this includes the + /// attributes from base types. + /// + public IReadOnlyCollection Attributes { get; } + + /// + /// Exposed resource relationships. See https://jsonapi.org/format/#document-resource-object-relationships. When using resource inheritance, this + /// includes the relationships from base types. + /// + public IReadOnlyCollection Relationships { get; } + + /// + /// Related entities that are not exposed as resource relationships. When using resource inheritance, this includes the eager-loads from base types. + /// + public IReadOnlyCollection EagerLoads { get; } + + /// + /// 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. + /// + /// + /// In the process of building the resource graph, this value is set based on usage. + /// + public LinkTypes TopLevelLinks { get; } + + /// + /// 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. + /// + /// + /// In the process of building the resource graph, this value is set based on usage. + /// + public LinkTypes ResourceLinks { get; } + + /// + /// 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 + /// . + /// + /// + /// In the process of building the resource graph, this value is set based on usage. + /// + public LinkTypes RelationshipLinks { get; } + + 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, ClientIdGenerationMode? clientIdGeneration, Type clrType, Type identityClrType, + IReadOnlyCollection? attributes, IReadOnlyCollection? relationships, + IReadOnlyCollection? eagerLoads, LinkTypes topLevelLinks = LinkTypes.NotConfigured, + LinkTypes resourceLinks = LinkTypes.NotConfigured, LinkTypes relationshipLinks = LinkTypes.NotConfigured) + { + ArgumentException.ThrowIfNullOrWhiteSpace(publicName); + ArgumentNullException.ThrowIfNull(clrType); + ArgumentNullException.ThrowIfNull(identityClrType); + + PublicName = publicName; + ClientIdGeneration = clientIdGeneration; + ClrType = clrType; + IdentityClrType = identityClrType; + Attributes = attributes ?? Array.Empty(); + Relationships = relationships ?? Array.Empty(); + EagerLoads = eagerLoads ?? Array.Empty(); + TopLevelLinks = topLevelLinks; + ResourceLinks = resourceLinks; + RelationshipLinks = relationshipLinks; + Fields = Attributes.Cast().Concat(Relationships).ToArray().AsReadOnly(); + + foreach (ResourceFieldAttribute field in Fields) + { + _fieldsByPublicName.Add(field.PublicName, field); + _fieldsByPropertyName.Add(field.Property.Name, field); + } + + _lazyAllConcreteDerivedTypes = new Lazy>(ResolveAllConcreteDerivedTypes, LazyThreadSafetyMode.PublicationOnly); + } + + private IReadOnlySet ResolveAllConcreteDerivedTypes() + { + HashSet allConcreteDerivedTypes = []; + AddConcreteDerivedTypes(this, allConcreteDerivedTypes); + + return allConcreteDerivedTypes.AsReadOnly(); + } + + private static void AddConcreteDerivedTypes(ResourceType resourceType, ISet allConcreteDerivedTypes) + { + foreach (ResourceType derivedType in resourceType.DirectlyDerivedTypes) + { + if (!derivedType.ClrType.IsAbstract) + { + allConcreteDerivedTypes.Add(derivedType); + } + + AddConcreteDerivedTypes(derivedType, allConcreteDerivedTypes); + } + } + + public AttrAttribute GetAttributeByPublicName(string publicName) + { + AttrAttribute? attribute = FindAttributeByPublicName(publicName); + return attribute ?? throw new InvalidOperationException($"Attribute '{publicName}' does not exist on resource type '{PublicName}'."); + } + + public AttrAttribute? FindAttributeByPublicName(string publicName) + { + ArgumentNullException.ThrowIfNull(publicName); + + return _fieldsByPublicName.TryGetValue(publicName, out ResourceFieldAttribute? field) && field is AttrAttribute attribute ? attribute : null; + } + + 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) + { + ArgumentNullException.ThrowIfNull(propertyName); + + return _fieldsByPropertyName.TryGetValue(propertyName, out ResourceFieldAttribute? field) && field is AttrAttribute attribute ? attribute : null; + } + + public RelationshipAttribute GetRelationshipByPublicName(string publicName) + { + RelationshipAttribute? relationship = FindRelationshipByPublicName(publicName); + return relationship ?? throw new InvalidOperationException($"Relationship '{publicName}' does not exist on resource type '{PublicName}'."); + } + + public RelationshipAttribute? FindRelationshipByPublicName(string publicName) + { + ArgumentNullException.ThrowIfNull(publicName); + + return _fieldsByPublicName.TryGetValue(publicName, out ResourceFieldAttribute? field) && field is RelationshipAttribute relationship + ? relationship + : null; + } + + public RelationshipAttribute GetRelationshipByPropertyName(string propertyName) + { + RelationshipAttribute? relationship = FindRelationshipByPropertyName(propertyName); + + return relationship ?? + throw new InvalidOperationException($"Relationship for property '{propertyName}' does not exist on resource type '{ClrType.Name}'."); + } + + public RelationshipAttribute? FindRelationshipByPropertyName(string propertyName) + { + ArgumentNullException.ThrowIfNull(propertyName); + + return _fieldsByPropertyName.TryGetValue(propertyName, out ResourceFieldAttribute? field) && field is RelationshipAttribute relationship + ? relationship + : null; + } + + /// + /// Returns all non-abstract resource types that directly or indirectly derive from this resource type. + /// + public IReadOnlySet GetAllConcreteDerivedTypes() + { + return _lazyAllConcreteDerivedTypes.Value; + } + + /// + /// Searches the tree of derived types to find a match for the specified . + /// + public ResourceType GetTypeOrDerived(Type clrType) + { + ArgumentNullException.ThrowIfNull(clrType); + + ResourceType? derivedType = FindTypeOrDerived(this, clrType); + + if (derivedType == null) + { + throw new InvalidOperationException($"Resource type '{PublicName}' is not a base type of '{clrType}'."); + } + + return derivedType; + } + + private static ResourceType? FindTypeOrDerived(ResourceType type, Type clrType) + { + if (type.ClrType == clrType) + { + return type; + } + + foreach (ResourceType derivedType in type.DirectlyDerivedTypes) + { + ResourceType? matchingType = FindTypeOrDerived(derivedType, clrType); + + if (matchingType != null) + { + return matchingType; + } + } + + return null; + } + + internal IReadOnlySet GetAttributesInTypeOrDerived(string 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) + { + AttrAttribute? attribute = resourceType.FindAttributeByPublicName(publicName); + + if (attribute != null) + { + 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 = []; + + foreach (AttrAttribute attributeInDerivedType in resourceType.DirectlyDerivedTypes + .Select(derivedType => GetAttributesInTypeOrDerived(derivedType, publicName)).SelectMany(attributesInDerivedType => attributesInDerivedType)) + { + attributesInDerivedTypes.Add(attributeInDerivedType); + } + + return attributesInDerivedTypes.AsReadOnly(); + } + + internal IReadOnlySet GetRelationshipsInTypeOrDerived(string 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) + { + RelationshipAttribute? relationship = resourceType.FindRelationshipByPublicName(publicName); + + if (relationship != null) + { + 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 = []; + + foreach (RelationshipAttribute relationshipInDerivedType in resourceType.DirectlyDerivedTypes + .Select(derivedType => GetRelationshipsInTypeOrDerived(derivedType, publicName)) + .SelectMany(relationshipsInDerivedType => relationshipsInDerivedType)) + { + relationshipsInDerivedTypes.Add(relationshipInDerivedType); + } + + return relationshipsInDerivedTypes.AsReadOnly(); + } + + internal bool IsPartOfTypeHierarchy() + { + return BaseType != null || DirectlyDerivedTypes.Count > 0; + } + + public override string ToString() + { + return PublicName; + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj is null || GetType() != obj.GetType()) + { + return false; + } + + var other = (ResourceType)obj; + + return PublicName == other.PublicName && ClrType == other.ClrType && IdentityClrType == other.IdentityClrType && + Attributes.SequenceEqual(other.Attributes) && Relationships.SequenceEqual(other.Relationships) && EagerLoads.SequenceEqual(other.EagerLoads) && + TopLevelLinks == other.TopLevelLinks && ResourceLinks == other.ResourceLinks && RelationshipLinks == other.RelationshipLinks; + } + + public override int GetHashCode() + { + var hashCode = new HashCode(); + + hashCode.Add(PublicName); + hashCode.Add(ClrType); + hashCode.Add(IdentityClrType); + + foreach (AttrAttribute attribute in Attributes) + { + hashCode.Add(attribute); + } + + foreach (RelationshipAttribute relationship in Relationships) + { + hashCode.Add(relationship); + } + + foreach (EagerLoadAttribute eagerLoad in EagerLoads) + { + hashCode.Add(eagerLoad); + } + + hashCode.Add(TopLevelLinks); + hashCode.Add(ResourceLinks); + hashCode.Add(RelationshipLinks); + + return hashCode.ToHashCode(); + } +} 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 new file mode 100644 index 0000000000..ed36e0797c --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/JsonApiDotNetCore.Annotations.csproj @@ -0,0 +1,50 @@ + + + net8.0;netstandard1.0 + true + true + JsonApiDotNetCore + + + + + + jsonapidotnetcore;jsonapi;json:api;dotnet;asp.net;rest;web-api + 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. + package-icon.png + PackageReadme.md + true + embedded + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 new file mode 100644 index 0000000000..b4ac4b1e8d --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Properties/AssemblyInfo.cs @@ -0,0 +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 new file mode 100644 index 0000000000..26a660775a --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/AttrAttribute.cs @@ -0,0 +1,57 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Resources.Annotations; + +/// +/// Used to expose a property on a resource class as a JSON:API attribute (https://jsonapi.org/format/#document-resource-object-attributes). +/// +[PublicAPI] +[AttributeUsage(AttributeTargets.Property)] +public sealed class AttrAttribute : ResourceFieldAttribute +{ + private AttrCapabilities? _capabilities; + + internal bool HasExplicitCapabilities => _capabilities != null; + + /// + /// The set of allowed capabilities on this attribute. When not explicitly set, the configured default set of capabilities is used. + /// + /// + /// + /// { + /// [Attr(Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort)] + /// public string Name { get; set; } = null!; + /// } + /// ]]> + /// + public AttrCapabilities Capabilities + { + get => _capabilities ?? default; + set => _capabilities = value; + } + + /// + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj is null || GetType() != obj.GetType()) + { + return false; + } + + var other = (AttrAttribute)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.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.cs b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/EagerLoadAttribute.cs new file mode 100644 index 0000000000..7879d74e88 --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/EagerLoadAttribute.cs @@ -0,0 +1,44 @@ +using System.Reflection; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Resources.Annotations; + +/// +/// Used to unconditionally load a related entity that is not exposed as a JSON:API relationship. +/// +/// +/// This is intended for calculated properties that are exposed as JSON:API attributes, which depend on a related entity to always be loaded. +/// Name.First + " " + Name.Last; +/// +/// [EagerLoad] +/// public Name Name { get; set; } +/// } +/// +/// public class Name // not exposed as resource, only database table +/// { +/// public string First { get; set; } +/// public string Last { get; set; } +/// } +/// +/// public class Blog : Identifiable +/// { +/// [HasOne] +/// public User Author { get; set; } +/// } +/// ]]> +/// +[PublicAPI] +[AttributeUsage(AttributeTargets.Property)] +public sealed class EagerLoadAttribute : Attribute +{ + // These properties are definitely assigned after building the resource graph, which is why they are declared as non-nullable. + + public PropertyInfo Property { get; internal set; } = null!; + + public IReadOnlyCollection Children { get; internal set; } = null!; +} 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 new file mode 100644 index 0000000000..a906f4a667 --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasManyAttribute.cs @@ -0,0 +1,141 @@ +using System.Collections; +using JetBrains.Annotations; + +// ReSharper disable NonReadonlyMemberInGetHashCode + +namespace JsonApiDotNetCore.Resources.Annotations; + +/// +/// Used to expose a property on a resource class as a JSON:API to-many relationship +/// (https://jsonapi.org/format/#document-resource-object-relationships). +/// +/// +/// Articles { get; set; } +/// } +/// ]]> +/// +[PublicAPI] +[AttributeUsage(AttributeTargets.Property)] +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); + } + + private bool EvaluateIsManyToMany() + { + if (InverseNavigationProperty != null) + { + 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 new file mode 100644 index 0000000000..51d22f9955 --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/HasOneAttribute.cs @@ -0,0 +1,97 @@ +using JetBrains.Annotations; + +// ReSharper disable NonReadonlyMemberInGetHashCode + +namespace JsonApiDotNetCore.Resources.Annotations; + +/// +/// Used to expose a property on a resource class as a JSON:API to-one relationship (https://jsonapi.org/format/#document-resource-object-relationships). +/// +/// +/// +/// +[PublicAPI] +[AttributeUsage(AttributeTargets.Property)] +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); + } + + private bool EvaluateIsOneToOne() + { + if (InverseNavigationProperty != null) + { + 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.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.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 new file mode 100644 index 0000000000..21d5bfab1d --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/RelationshipAttribute.cs @@ -0,0 +1,110 @@ +using System.Reflection; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; + +// ReSharper disable NonReadonlyMemberInGetHashCode + +namespace JsonApiDotNetCore.Resources.Annotations; + +/// +/// Used to expose a property on a resource class as a JSON:API relationship (https://jsonapi.org/format/#document-resource-object-relationships). +/// +[PublicAPI] +public abstract class RelationshipAttribute : ResourceFieldAttribute +{ + // 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. + /// + /// + /// Articles { get; set; } + /// } + /// ]]> + /// + public PropertyInfo? InverseNavigationProperty { get; set; } + + /// + /// The containing resource type in which this relationship is declared. Identical to . + /// + public ResourceType LeftType => Type; + + /// + /// The resource type this relationship points to. In the case of a relationship, this value will be the collection + /// element type. + /// + /// + /// Tags { get; set; } // RightType: Tag + /// ]]> + /// + public ResourceType RightType + { + get => _rightType!; + internal set + { + ArgumentNullException.ThrowIfNull(value); + _rightType = value; + } + } + + /// + /// 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; } + + /// + /// Whether or not this relationship can be included using the include query string parameter. This is true by default. + /// + /// + /// 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)) + { + return true; + } + + if (obj is null || GetType() != obj.GetType()) + { + return false; + } + + var other = (RelationshipAttribute)obj; + + return _rightType?.ClrType == other._rightType?.ClrType && Links == other.Links && base.Equals(other); + } + + /// + public override int 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.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 new file mode 100644 index 0000000000..593b0a905d --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Resources/Annotations/ResourceFieldAttribute.cs @@ -0,0 +1,155 @@ +using System.Reflection; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; + +// ReSharper disable NonReadonlyMemberInGetHashCode + +namespace JsonApiDotNetCore.Resources.Annotations; + +/// +/// Used to expose a property on a resource class as a JSON:API field (attribute or relationship). See +/// https://jsonapi.org/format/#document-resource-object-fields. +/// +[PublicAPI] +public abstract class ResourceFieldAttribute : Attribute +{ + // These are definitely assigned after building the resource graph, which is why their public equivalents are declared as non-nullable. + private string? _publicName; + private PropertyInfo? _property; + private ResourceType? _type; + + /// + /// 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 + { + get => _publicName!; + set + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("Exposed name cannot be null, empty or contain only whitespace.", nameof(value)); + } + + _publicName = value; + } + } + + /// + /// The resource property that this attribute is declared on. + /// + public PropertyInfo Property + { + get => _property!; + internal set + { + ArgumentNullException.ThrowIfNull(value); + _property = value; + } + } + + /// + /// The containing resource type in which this field is declared. + /// + public ResourceType Type + { + get => _type!; + internal set + { + ArgumentNullException.ThrowIfNull(value); + _type = value; + } + } + + /// + /// Gets the value of this field on the specified resource instance. Throws if the property is write-only or if the field does not belong to the + /// specified resource instance. + /// + public object? GetValue(object resource) + { + ArgumentNullException.ThrowIfNull(resource); + AssertIsIdentifiable(resource); + + if (Property.GetMethod == null) + { + throw new InvalidOperationException($"Property '{Property.DeclaringType?.Name}.{Property.Name}' is write-only."); + } + + try + { + return Property.GetValue(resource); + } + catch (TargetException exception) + { + throw new InvalidOperationException( + $"Unable to get property value of '{Property.DeclaringType!.Name}.{Property.Name}' on instance of type '{resource.GetType().Name}'.", + exception.InnerException ?? exception); + } + } + + /// + /// 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 virtual void SetValue(object resource, object? newValue) + { + ArgumentNullException.ThrowIfNull(resource); + AssertIsIdentifiable(resource); + + if (Property.SetMethod == null) + { + throw new InvalidOperationException($"Property '{Property.DeclaringType?.Name}.{Property.Name}' is read-only."); + } + + try + { + Property.SetValue(resource, newValue); + } + catch (TargetException exception) + { + throw new InvalidOperationException( + $"Unable to set property value of '{Property.DeclaringType!.Name}.{Property.Name}' on instance of type '{resource.GetType().Name}'.", + 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)) + { + return true; + } + + if (obj is null || GetType() != obj.GetType()) + { + return false; + } + + var other = (ResourceFieldAttribute)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.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.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 new file mode 100644 index 0000000000..c129b4fdab --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/Resources/Identifiable.cs @@ -0,0 +1,44 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace JsonApiDotNetCore.Resources; + +/// +/// A convenient basic implementation of that provides conversion between typed and +/// . +/// +/// +/// The resource identifier type. +/// +public abstract class Identifiable : IIdentifiable +{ + /// + public virtual TId Id { get; set; } = default!; + + /// + [NotMapped] + public string? StringId + { + get => GetStringId(Id); + set => Id = GetTypedId(value); + } + + /// + [NotMapped] + public string? LocalId { get; set; } + + /// + /// Converts an outgoing typed resource identifier to string format for use in a JSON:API response. + /// + protected virtual string? GetStringId(TId value) + { + return EqualityComparer.Default.Equals(value, default) ? null : value!.ToString(); + } + + /// + /// Converts an incoming 'id' element from a JSON:API request to the typed resource identifier. + /// + protected virtual TId GetTypedId(string? value) + { + return value == null ? default! : (TId)RuntimeTypeConverter.ConvertType(value, typeof(TId))!; + } +} 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/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 new file mode 100644 index 0000000000..89b8158554 --- /dev/null +++ b/src/JsonApiDotNetCore.Annotations/TypeExtensions.cs @@ -0,0 +1,56 @@ +namespace JsonApiDotNetCore; + +internal static class TypeExtensions +{ + /// + /// Whether the specified source type implements or equals the specified interface. + /// + public static bool IsOrImplementsInterface(this Type? source) + { + return IsOrImplementsInterface(source, typeof(TInterface)); + } + + /// + /// Whether the specified source type implements or equals the specified interface. This overload enables to test for an open generic interface. + /// + private static bool IsOrImplementsInterface(this Type? source, Type interfaceType) + { + ArgumentNullException.ThrowIfNull(interfaceType); + + if (source == null) + { + return false; + } + + return AreTypesEqual(interfaceType, source, interfaceType.IsGenericType) || + source.GetInterfaces().Any(type => AreTypesEqual(interfaceType, type, interfaceType.IsGenericType)); + } + + private static bool AreTypesEqual(Type left, Type right, bool isLeftGeneric) + { + return isLeftGeneric ? right.IsGenericType && right.GetGenericTypeDefinition() == left : left == right; + } + + /// + /// Gets the name of a type, including the names of its generic type arguments. + /// + /// > + /// ]]> + /// + /// + public static string GetFriendlyTypeName(this Type 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('`')]}<{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 new file mode 100644 index 0000000000..1b47821d22 --- /dev/null +++ b/src/JsonApiDotNetCore.SourceGenerators/ControllerSourceGenerator.cs @@ -0,0 +1,168 @@ +using System.Collections.Immutable; +using System.Text; +using Humanizer; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +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 + +[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) + { + context.RegisterForSyntaxNotifications(CreateSyntaxReceiver); + } + + public void Execute(GeneratorExecutionContext context) + { + var receiver = (TypeWithAttributeSyntaxReceiver?)context.SyntaxReceiver; + + if (receiver == null) + { + return; + } + + 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"); + + if (resourceAttributeType == null || identifiableOpenInterface == null || loggerFactoryInterface == null) + { + return; + } + + var controllerNamesInUse = new Dictionary(StringComparer.OrdinalIgnoreCase); + var writer = new SourceCodeWriter(context, MissingIndentInTableError); + + foreach (TypeDeclarationSyntax? typeDeclarationSyntax in receiver.TypeDeclarations) + { + // 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, + static (data, type) => SymbolEqualityComparer.Default.Equals(data.AttributeClass, type)); + + if (resourceAttributeData == null) + { + continue; + } + + TypedConstant endpointsArgument = + resourceAttributeData.NamedArguments.FirstOrDefault(static pair => pair.Key == "GenerateControllerEndpoints").Value; + + if (endpointsArgument.Value != null && (JsonApiEndpointsCopy)endpointsArgument.Value == JsonApiEndpointsCopy.None) + { + continue; + } + + TypedConstant controllerNamespaceArgument = + resourceAttributeData.NamedArguments.FirstOrDefault(static pair => pair.Key == "ControllerNamespace").Value; + + string? controllerNamespace = GetControllerNamespace(controllerNamespaceArgument, resourceType); + + INamedTypeSymbol? identifiableClosedInterface = FirstOrDefault(resourceType.AllInterfaces, identifiableOpenInterface, + static (@interface, openInterface) => + @interface.IsGenericType && SymbolEqualityComparer.Default.Equals(@interface.ConstructedFrom, openInterface)); + + if (identifiableClosedInterface == null) + { + var diagnostic = Diagnostic.Create(MissingInterfaceWarning, typeDeclarationSyntax.GetLocation(), resourceType.Name); + context.ReportDiagnostic(diagnostic); + continue; + } + + ITypeSymbol idType = identifiableClosedInterface.TypeArguments[0]; + string controllerName = $"{resourceType.Name.Pluralize()}Controller"; + JsonApiEndpointsCopy endpointsToGenerate = (JsonApiEndpointsCopy?)(int?)endpointsArgument.Value ?? JsonApiEndpointsCopy.All; + + string sourceCode = writer.Write(resourceType, idType, endpointsToGenerate, controllerNamespace, controllerName, loggerFactoryInterface); + SourceText sourceText = SourceText.From(sourceCode, Encoding.UTF8); + + string fileName = GetUniqueFileName(controllerName, controllerNamesInUse); + context.AddSource(fileName, sourceText); + } + } + + 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 (predicate(element, context)) + { + return element; + } + } + + return default; + } + + private static string? GetControllerNamespace(TypedConstant controllerNamespaceArgument, INamedTypeSymbol resourceType) + { + if (!controllerNamespaceArgument.IsNull) + { + return (string?)controllerNamespaceArgument.Value; + } + + if (resourceType.ContainingNamespace.IsGlobalNamespace) + { + return null; + } + + if (resourceType.ContainingNamespace.ContainingNamespace.IsGlobalNamespace) + { + return "Controllers"; + } + + return $"{resourceType.ContainingNamespace.ContainingNamespace}.Controllers"; + } + + 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. + + 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 new file mode 100644 index 0000000000..db6f039bd1 --- /dev/null +++ b/src/JsonApiDotNetCore.SourceGenerators/JsonApiDotNetCore.SourceGenerators.csproj @@ -0,0 +1,51 @@ + + + netstandard2.0 + true + true + false + $(NoWarn);NU5128 + true + + + + + + jsonapidotnetcore;jsonapi;json:api;dotnet;asp.net;rest;web-api + 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. + package-icon.png + PackageReadme.md + https://github.com/json-api-dotnet/JsonApiDotNetCore + + + + + + + + + + + + + + + $(GetTargetPathDependsOn);GetDependencyTargetPaths + + + + + + + + + + + + + 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 new file mode 100644 index 0000000000..911be3f359 --- /dev/null +++ b/src/JsonApiDotNetCore.SourceGenerators/JsonApiEndpointsCopy.cs @@ -0,0 +1,23 @@ +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 +{ + 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.SourceGenerators/Properties/launchSettings.json b/src/JsonApiDotNetCore.SourceGenerators/Properties/launchSettings.json new file mode 100644 index 0000000000..03635841ec --- /dev/null +++ b/src/JsonApiDotNetCore.SourceGenerators/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "JsonApiDotNetCore.SourceGenerators": { + "commandName": "DebugRoslynComponent", + "targetProject": "..\\Examples\\JsonApiDotNetCoreExample\\JsonApiDotNetCoreExample.csproj" + } + } +} diff --git a/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs b/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs new file mode 100644 index 0000000000..3df1092c4b --- /dev/null +++ b/src/JsonApiDotNetCore.SourceGenerators/SourceCodeWriter.cs @@ -0,0 +1,254 @@ +using System.Text; +using Microsoft.CodeAnalysis; + +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) +{ + private const int SpacesPerIndent = 4; + + private static readonly Dictionary IndentTable = new() + { + [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 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; + + WriteAutoGeneratedComment(); + + if (idType is { IsReferenceType: true, NullableAnnotation: NullableAnnotation.Annotated }) + { + WriteNullableEnable(); + } + + WriteNamespaceImports(loggerFactoryInterface, resourceType, controllerNamespace); + + if (controllerNamespace != null) + { + WriteNamespaceDeclaration(controllerNamespace); + } + + WriteOpenClassDeclaration(controllerName, endpointsToGenerate, resourceType, idType); + _depth++; + + WriteConstructor(controllerName, loggerFactoryInterface, endpointsToGenerate, resourceType, idType); + + _depth--; + WriteCloseCurly(); + + return _sourceBuilder.ToString(); + } + + private void WriteAutoGeneratedComment() + { + _sourceBuilder.AppendLine("// "); + _sourceBuilder.AppendLine(); + } + + private void WriteNullableEnable() + { + _sourceBuilder.AppendLine("#nullable enable"); + _sourceBuilder.AppendLine(); + } + + private void WriteNamespaceImports(INamedTypeSymbol loggerFactoryInterface, INamedTypeSymbol resourceType, string? controllerNamespace) + { + _sourceBuilder.AppendLine($"using {loggerFactoryInterface.ContainingNamespace};"); + + _sourceBuilder.AppendLine("using JsonApiDotNetCore.Configuration;"); + _sourceBuilder.AppendLine("using JsonApiDotNetCore.Controllers;"); + _sourceBuilder.AppendLine("using JsonApiDotNetCore.Services;"); + + if (!resourceType.ContainingNamespace.IsGlobalNamespace && resourceType.ContainingNamespace.ToString() != controllerNamespace) + { + _sourceBuilder.AppendLine($"using {resourceType.ContainingNamespace};"); + } + + _sourceBuilder.AppendLine(); + } + + private void WriteNamespaceDeclaration(string controllerNamespace) + { + _sourceBuilder.AppendLine($"namespace {controllerNamespace};"); + _sourceBuilder.AppendLine(); + } + + private void WriteOpenClassDeclaration(string controllerName, JsonApiEndpointsCopy endpointsToGenerate, INamedTypeSymbol resourceType, ITypeSymbol idType) + { + string baseClassName = GetControllerBaseClassName(endpointsToGenerate); + + WriteIndent(); + _sourceBuilder.AppendLine($"public sealed partial class {controllerName} : {baseClassName}<{resourceType.Name}, {idType}>"); + + WriteOpenCurly(); + } + + private static string GetControllerBaseClassName(JsonApiEndpointsCopy endpointsToGenerate) + { + return endpointsToGenerate switch + { + 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 {controllerName}(IJsonApiOptions options, IResourceGraph resourceGraph, {loggerName} loggerFactory,"); + + _depth++; + + if (AggregateEndpointToServiceNameMap.TryGetValue(endpointsToGenerate, out (string ServiceName, string ParameterName) value)) + { + WriteParameterListForShortConstructor(value.ServiceName, value.ParameterName, resourceType, idType); + } + else + { + WriteParameterListForLongConstructor(endpointsToGenerate, resourceType, idType); + } + + _depth--; + + WriteOpenCurly(); + WriteCloseCurly(); + } + + 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})"); + } + + private void WriteParameterListForLongConstructor(JsonApiEndpointsCopy endpointsToGenerate, INamedTypeSymbol resourceType, ITypeSymbol idType) + { + bool isFirstEntry = true; + + foreach (KeyValuePair entry in EndpointToServiceNameMap) + { + if ((endpointsToGenerate & entry.Key) == entry.Key) + { + if (isFirstEntry) + { + isFirstEntry = false; + } + else + { + _sourceBuilder.AppendLine(Tokens.Comma); + } + + WriteIndent(); + _sourceBuilder.Append($"{entry.Value.ServiceName}<{resourceType.Name}, {idType}> {entry.Value.ParameterName}"); + } + } + + _sourceBuilder.AppendLine(Tokens.CloseParen); + + WriteIndent(); + _sourceBuilder.AppendLine(": base(options, resourceGraph, loggerFactory,"); + + isFirstEntry = true; + _depth++; + + foreach (KeyValuePair entry in EndpointToServiceNameMap) + { + if ((endpointsToGenerate & entry.Key) == entry.Key) + { + if (isFirstEntry) + { + isFirstEntry = false; + } + else + { + _sourceBuilder.AppendLine(Tokens.Comma); + } + + WriteIndent(); + _sourceBuilder.Append($"{entry.Value.ParameterName}: {entry.Value.ParameterName}"); + } + } + + _sourceBuilder.AppendLine(Tokens.CloseParen); + _depth--; + } + + private void WriteOpenCurly() + { + WriteIndent(); + _sourceBuilder.AppendLine(Tokens.OpenCurly); + } + + private void WriteCloseCurly() + { + WriteIndent(); + _sourceBuilder.AppendLine(Tokens.CloseCurly); + } + + 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); + + 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 +} diff --git a/src/JsonApiDotNetCore.SourceGenerators/TypeWithAttributeSyntaxReceiver.cs b/src/JsonApiDotNetCore.SourceGenerators/TypeWithAttributeSyntaxReceiver.cs new file mode 100644 index 0000000000..17c5ffefd0 --- /dev/null +++ b/src/JsonApiDotNetCore.SourceGenerators/TypeWithAttributeSyntaxReceiver.cs @@ -0,0 +1,38 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +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 +{ + public readonly ISet TypeDeclarations = new HashSet(); + + public void OnVisitSyntaxNode(SyntaxNode syntaxNode) + { + if (syntaxNode is TypeDeclarationSyntax { AttributeLists.Count: > 0 } typeDeclarationSyntax) + { + TypeDeclarations.Add(typeDeclarationSyntax); + } + } +} diff --git a/src/JsonApiDotNetCore/ArgumentGuard.cs b/src/JsonApiDotNetCore/ArgumentGuard.cs deleted file mode 100644 index c9f9e2d6a7..0000000000 --- a/src/JsonApiDotNetCore/ArgumentGuard.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using JetBrains.Annotations; - -#pragma warning disable AV1008 // Class should not be static - -namespace JsonApiDotNetCore -{ - internal static class ArgumentGuard - { - [AssertionMethod] - [ContractAnnotation("value: null => halt")] - public static void NotNull([CanBeNull] [NoEnumeration] T value, [NotNull] [InvokerParameterName] string name) - where T : class - { - if (value is null) - { - throw new ArgumentNullException(name); - } - } - - [AssertionMethod] - [ContractAnnotation("value: null => halt")] - public static void NotNullNorEmpty([CanBeNull] IEnumerable value, [NotNull] [InvokerParameterName] string name, - [CanBeNull] string collectionName = null) - { - NotNull(value, name); - - if (!value.Any()) - { - throw new ArgumentException($"Must have one or more {collectionName ?? name}.", name); - } - } - - [AssertionMethod] - [ContractAnnotation("value: null => halt")] - public static void NotNullNorEmpty([CanBeNull] string value, [NotNull] [InvokerParameterName] string name) - { - NotNull(value, name); - - if (value == string.Empty) - { - throw new ArgumentException("String cannot be null or empty.", name); - } - } - } -} diff --git a/src/JsonApiDotNetCore/ArrayFactory.cs b/src/JsonApiDotNetCore/ArrayFactory.cs deleted file mode 100644 index a33102cbdd..0000000000 --- a/src/JsonApiDotNetCore/ArrayFactory.cs +++ /dev/null @@ -1,13 +0,0 @@ -#pragma warning disable AV1008 // Class should not be static -#pragma warning disable AV1130 // Return type in method signature should be a collection interface instead of a concrete type - -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 df87d1c546..d045bc0814 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransaction.cs @@ -1,60 +1,57 @@ -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Repositories; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; -namespace JsonApiDotNetCore.AtomicOperations +namespace JsonApiDotNetCore.AtomicOperations; + +/// +/// Represents an Entity Framework Core transaction in an atomic:operations request. +/// +[PublicAPI] +public sealed class EntityFrameworkCoreTransaction : IOperationsTransaction { + private readonly IDbContextTransaction _transaction; + private readonly DbContext _dbContext; + + /// + public string TransactionId => _transaction.TransactionId.ToString(); + + public EntityFrameworkCoreTransaction(IDbContextTransaction transaction, DbContext dbContext) + { + ArgumentNullException.ThrowIfNull(transaction); + ArgumentNullException.ThrowIfNull(dbContext); + + _transaction = transaction; + _dbContext = dbContext; + } + + /// + /// Detaches all entities from the Entity Framework Core change tracker. + /// + public Task BeforeProcessOperationAsync(CancellationToken cancellationToken) + { + _dbContext.ResetChangeTracker(); + return Task.CompletedTask; + } + /// - /// Represents an Entity Framework Core transaction in an atomic:operations request. + /// Does nothing. /// - [PublicAPI] - public sealed class EntityFrameworkCoreTransaction : IOperationsTransaction + public Task AfterProcessOperationAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + public Task CommitAsync(CancellationToken cancellationToken) + { + return _transaction.CommitAsync(cancellationToken); + } + + /// + public ValueTask DisposeAsync() { - private readonly IDbContextTransaction _transaction; - private readonly DbContext _dbContext; - - /// - public string TransactionId => _transaction.TransactionId.ToString(); - - public EntityFrameworkCoreTransaction(IDbContextTransaction transaction, DbContext dbContext) - { - ArgumentGuard.NotNull(transaction, nameof(transaction)); - ArgumentGuard.NotNull(dbContext, nameof(dbContext)); - - _transaction = transaction; - _dbContext = dbContext; - } - - /// - /// Detaches all entities from the Entity Framework Core change tracker. - /// - public Task BeforeProcessOperationAsync(CancellationToken cancellationToken) - { - _dbContext.ResetChangeTracker(); - return Task.CompletedTask; - } - - /// - /// Does nothing. - /// - public Task AfterProcessOperationAsync(CancellationToken cancellationToken) - { - return Task.CompletedTask; - } - - /// - public Task CommitAsync(CancellationToken cancellationToken) - { - return _transaction.CommitAsync(cancellationToken); - } - - /// - public ValueTask DisposeAsync() - { - return _transaction.DisposeAsync(); - } + return _transaction.DisposeAsync(); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransactionFactory.cs b/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransactionFactory.cs index 3d30ebe089..2fe6959b1f 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransactionFactory.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/EntityFrameworkCoreTransactionFactory.cs @@ -1,39 +1,36 @@ -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Repositories; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; -namespace JsonApiDotNetCore.AtomicOperations +namespace JsonApiDotNetCore.AtomicOperations; + +/// +/// Provides transaction support for atomic:operation requests using Entity Framework Core. +/// +public sealed class EntityFrameworkCoreTransactionFactory : IOperationsTransactionFactory { - /// - /// Provides transaction support for atomic:operation requests using Entity Framework Core. - /// - public sealed class EntityFrameworkCoreTransactionFactory : IOperationsTransactionFactory - { - private readonly IDbContextResolver _dbContextResolver; - private readonly IJsonApiOptions _options; + private readonly IDbContextResolver _dbContextResolver; + private readonly IJsonApiOptions _options; - public EntityFrameworkCoreTransactionFactory(IDbContextResolver dbContextResolver, IJsonApiOptions options) - { - ArgumentGuard.NotNull(dbContextResolver, nameof(dbContextResolver)); - ArgumentGuard.NotNull(options, nameof(options)); + public EntityFrameworkCoreTransactionFactory(IDbContextResolver dbContextResolver, IJsonApiOptions options) + { + ArgumentNullException.ThrowIfNull(dbContextResolver); + ArgumentNullException.ThrowIfNull(options); - _dbContextResolver = dbContextResolver; - _options = options; - } + _dbContextResolver = dbContextResolver; + _options = options; + } - /// - public async Task BeginTransactionAsync(CancellationToken cancellationToken) - { - DbContext dbContext = _dbContextResolver.GetContext(); + /// + public async Task BeginTransactionAsync(CancellationToken cancellationToken) + { + DbContext dbContext = _dbContextResolver.GetContext(); - IDbContextTransaction transaction = _options.TransactionIsolationLevel != null - ? await dbContext.Database.BeginTransactionAsync(_options.TransactionIsolationLevel.Value, cancellationToken) - : await dbContext.Database.BeginTransactionAsync(cancellationToken); + IDbContextTransaction transaction = _options.TransactionIsolationLevel != null + ? await dbContext.Database.BeginTransactionAsync(_options.TransactionIsolationLevel.Value, cancellationToken) + : await dbContext.Database.BeginTransactionAsync(cancellationToken); - return new EntityFrameworkCoreTransaction(transaction, dbContext); - } + return new EntityFrameworkCoreTransaction(transaction, dbContext); } } 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/ILocalIdTracker.cs b/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs index eb61e41371..c6311013af 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/ILocalIdTracker.cs @@ -1,28 +1,29 @@ -namespace JsonApiDotNetCore.AtomicOperations +using JsonApiDotNetCore.Configuration; + +namespace JsonApiDotNetCore.AtomicOperations; + +/// +/// Used to track declarations, assignments and references to local IDs an in atomic:operations request. +/// +public interface ILocalIdTracker { /// - /// Used to track declarations, assignments and references to local IDs an in atomic:operations request. + /// Removes all declared and assigned values. /// - public interface ILocalIdTracker - { - /// - /// Removes all declared and assigned values. - /// - void Reset(); + void Reset(); - /// - /// Declares a local ID without assigning a server-generated value. - /// - void Declare(string localId, string resourceType); + /// + /// Declares a local ID without assigning a server-generated value. + /// + void Declare(string localId, ResourceType resourceType); - /// - /// Assigns a server-generated ID value to a previously declared local ID. - /// - void Assign(string localId, string resourceType, string stringId); + /// + /// Assigns a server-generated ID value to a previously declared local ID. + /// + void Assign(string localId, ResourceType resourceType, string stringId); - /// - /// Gets the server-assigned ID for the specified local ID. - /// - string GetValue(string localId, string resourceType); - } + /// + /// Gets the server-assigned ID for the specified local ID. + /// + string GetValue(string localId, ResourceType resourceType); } diff --git a/src/JsonApiDotNetCore/AtomicOperations/IOperationProcessorAccessor.cs b/src/JsonApiDotNetCore/AtomicOperations/IOperationProcessorAccessor.cs index 693bd6098b..fec9039619 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/IOperationProcessorAccessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/IOperationProcessorAccessor.cs @@ -1,18 +1,15 @@ -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.AtomicOperations.Processors; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.AtomicOperations +namespace JsonApiDotNetCore.AtomicOperations; + +/// +/// Retrieves an instance from the D/I container and invokes a method on it. +/// +public interface IOperationProcessorAccessor { /// - /// Retrieves an instance from the D/I container and invokes a method on it. + /// Invokes on a processor compatible with the operation kind. /// - public interface IOperationProcessorAccessor - { - /// - /// Invokes on a processor compatible with the operation kind. - /// - Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken); - } + Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/AtomicOperations/IOperationsProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/IOperationsProcessor.cs index 839d0d6cb0..74927f0147 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/IOperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/IOperationsProcessor.cs @@ -1,18 +1,14 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.AtomicOperations +namespace JsonApiDotNetCore.AtomicOperations; + +/// +/// Atomically processes a request that contains a list of operations. +/// +public interface IOperationsProcessor { /// - /// Atomically processes a request that contains a list of operations. + /// Processes the list of specified operations. /// - public interface IOperationsProcessor - { - /// - /// Processes the list of specified operations. - /// - Task> ProcessAsync(IList operations, CancellationToken cancellationToken); - } + Task> ProcessAsync(IList operations, CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransaction.cs b/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransaction.cs index 4eed23455d..d7e4ee4b2c 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransaction.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransaction.cs @@ -1,34 +1,30 @@ -using System; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; -namespace JsonApiDotNetCore.AtomicOperations +namespace JsonApiDotNetCore.AtomicOperations; + +/// +/// Represents the overarching transaction in an atomic:operations request. +/// +[PublicAPI] +public interface IOperationsTransaction : IAsyncDisposable { /// - /// Represents the overarching transaction in an atomic:operations request. + /// Identifies the active transaction. /// - [PublicAPI] - public interface IOperationsTransaction : IAsyncDisposable - { - /// - /// Identifies the active transaction. - /// - string TransactionId { get; } + string TransactionId { get; } - /// - /// Enables to execute custom logic before processing of an operation starts. - /// - Task BeforeProcessOperationAsync(CancellationToken cancellationToken); + /// + /// Enables to execute custom logic before processing of an operation starts. + /// + Task BeforeProcessOperationAsync(CancellationToken cancellationToken); - /// - /// Enables to execute custom logic after processing of an operation succeeds. - /// - Task AfterProcessOperationAsync(CancellationToken cancellationToken); + /// + /// Enables to execute custom logic after processing of an operation succeeds. + /// + Task AfterProcessOperationAsync(CancellationToken cancellationToken); - /// - /// Commits all changes made to the underlying data store. - /// - Task CommitAsync(CancellationToken cancellationToken); - } + /// + /// Commits all changes made to the underlying data store. + /// + Task CommitAsync(CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransactionFactory.cs b/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransactionFactory.cs index f9b752381b..7f2da4675b 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransactionFactory.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/IOperationsTransactionFactory.cs @@ -1,16 +1,12 @@ -using System.Threading; -using System.Threading.Tasks; +namespace JsonApiDotNetCore.AtomicOperations; -namespace JsonApiDotNetCore.AtomicOperations +/// +/// Provides a method to start the overarching transaction for an atomic:operations request. +/// +public interface IOperationsTransactionFactory { /// - /// Provides a method to start the overarching transaction for an atomic:operations request. + /// Starts a new transaction. /// - public interface IOperationsTransactionFactory - { - /// - /// Starts a new transaction. - /// - Task BeginTransactionAsync(CancellationToken cancellationToken); - } + Task BeginTransactionAsync(CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs b/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs index 453267d828..bb058475e3 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/LocalIdTracker.cs @@ -1,123 +1,98 @@ -using System; -using System.Collections.Generic; -using System.Net; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.AtomicOperations +namespace JsonApiDotNetCore.AtomicOperations; + +/// +public sealed class LocalIdTracker : ILocalIdTracker { + private readonly Dictionary _idsTracked = new(); + /// - public sealed class LocalIdTracker : ILocalIdTracker + public void Reset() { - private readonly IDictionary _idsTracked = new Dictionary(); - - /// - public void Reset() - { - _idsTracked.Clear(); - } + _idsTracked.Clear(); + } - /// - public void Declare(string localId, string resourceType) - { - ArgumentGuard.NotNullNorEmpty(localId, nameof(localId)); - ArgumentGuard.NotNullNorEmpty(resourceType, nameof(resourceType)); + /// + public void Declare(string localId, ResourceType resourceType) + { + ArgumentException.ThrowIfNullOrEmpty(localId); + ArgumentNullException.ThrowIfNull(resourceType); - AssertIsNotDeclared(localId); + AssertIsNotDeclared(localId); - _idsTracked[localId] = new LocalIdState(resourceType); - } + _idsTracked[localId] = new LocalIdState(resourceType); + } - private void AssertIsNotDeclared(string localId) + private void AssertIsNotDeclared(string localId) + { + if (_idsTracked.ContainsKey(localId)) { - if (_idsTracked.ContainsKey(localId)) - { - throw new JsonApiException(new Error(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." - }); - } + throw new DuplicateLocalIdValueException(localId); } + } - /// - public void Assign(string localId, string resourceType, string stringId) - { - ArgumentGuard.NotNullNorEmpty(localId, nameof(localId)); - ArgumentGuard.NotNullNorEmpty(resourceType, nameof(resourceType)); - ArgumentGuard.NotNullNorEmpty(stringId, nameof(stringId)); - - AssertIsDeclared(localId); - - LocalIdState item = _idsTracked[localId]; + /// + public void Assign(string localId, ResourceType resourceType, string stringId) + { + ArgumentException.ThrowIfNullOrEmpty(localId); + ArgumentNullException.ThrowIfNull(resourceType); + ArgumentException.ThrowIfNullOrEmpty(stringId); - AssertSameResourceType(resourceType, item.ResourceType, localId); + AssertIsDeclared(localId); - if (item.ServerId != null) - { - throw new InvalidOperationException($"Cannot reassign to existing local ID '{localId}'."); - } + LocalIdState item = _idsTracked[localId]; - item.ServerId = stringId; - } + AssertSameResourceType(resourceType, item.ResourceType, localId); - /// - public string GetValue(string localId, string resourceType) + if (item.ServerId != null) { - ArgumentGuard.NotNullNorEmpty(localId, nameof(localId)); - ArgumentGuard.NotNullNorEmpty(resourceType, nameof(resourceType)); + throw new InvalidOperationException($"Cannot reassign to existing local ID '{localId}'."); + } - AssertIsDeclared(localId); + item.ServerId = stringId; + } - LocalIdState item = _idsTracked[localId]; + /// + public string GetValue(string localId, ResourceType resourceType) + { + ArgumentException.ThrowIfNullOrEmpty(localId); + ArgumentNullException.ThrowIfNull(resourceType); - AssertSameResourceType(resourceType, item.ResourceType, localId); + AssertIsDeclared(localId); - if (item.ServerId == null) - { - throw new JsonApiException(new Error(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." - }); - } + LocalIdState item = _idsTracked[localId]; - return item.ServerId; - } + AssertSameResourceType(resourceType, item.ResourceType, localId); - private void AssertIsDeclared(string localId) + if (item.ServerId == null) { - if (!_idsTracked.ContainsKey(localId)) - { - throw new JsonApiException(new Error(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." - }); - } + throw new LocalIdSingleOperationException(localId); } - private static void AssertSameResourceType(string currentType, string declaredType, string localId) + return item.ServerId; + } + + private void AssertIsDeclared(string localId) + { + if (!_idsTracked.ContainsKey(localId)) { - if (declaredType != currentType) - { - throw new JsonApiException(new Error(HttpStatusCode.BadRequest) - { - Title = "Type mismatch in local ID usage.", - Detail = $"Local ID '{localId}' belongs to resource type '{declaredType}' instead of '{currentType}'." - }); - } + throw new UnknownLocalIdValueException(localId); } + } - private sealed class LocalIdState + private static void AssertSameResourceType(ResourceType currentType, ResourceType declaredType, string localId) + { + if (!declaredType.Equals(currentType)) { - public string ResourceType { get; } - public string ServerId { get; set; } - - public LocalIdState(string resourceType) - { - ResourceType = resourceType; - } + throw new IncompatibleLocalIdTypeException(localId, declaredType.PublicName, currentType.PublicName); } } + + private sealed class LocalIdState(ResourceType resourceType) + { + public ResourceType ResourceType { get; } = resourceType; + public string? ServerId { get; set; } + } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs b/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs index 79ea1bf6ba..92d9bd8319 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/LocalIdValidator.cs @@ -1,4 +1,3 @@ -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; @@ -6,102 +5,99 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.AtomicOperations -{ - /// - /// Validates declaration, assignment and reference of local IDs within a list of operations. - /// - [PublicAPI] - public sealed class LocalIdValidator - { - private readonly ILocalIdTracker _localIdTracker; - private readonly IResourceContextProvider _resourceContextProvider; +namespace JsonApiDotNetCore.AtomicOperations; - public LocalIdValidator(ILocalIdTracker localIdTracker, IResourceContextProvider resourceContextProvider) - { - ArgumentGuard.NotNull(localIdTracker, nameof(localIdTracker)); - ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider)); +/// +/// Validates declaration, assignment and reference of local IDs within a list of operations. +/// +[PublicAPI] +public sealed class LocalIdValidator +{ + private readonly ILocalIdTracker _localIdTracker; + private readonly IResourceGraph _resourceGraph; - _localIdTracker = localIdTracker; - _resourceContextProvider = resourceContextProvider; - } + public LocalIdValidator(ILocalIdTracker localIdTracker, IResourceGraph resourceGraph) + { + ArgumentNullException.ThrowIfNull(localIdTracker); + ArgumentNullException.ThrowIfNull(resourceGraph); - public void Validate(IEnumerable operations) - { - ArgumentGuard.NotNull(operations, nameof(operations)); + _localIdTracker = localIdTracker; + _resourceGraph = resourceGraph; + } - _localIdTracker.Reset(); + public void Validate(IEnumerable operations) + { + ArgumentNullException.ThrowIfNull(operations); - int operationIndex = 0; + _localIdTracker.Reset(); - try - { - foreach (OperationContainer operation in operations) - { - ValidateOperation(operation); + int operationIndex = 0; - operationIndex++; - } - } - catch (JsonApiException exception) + try + { + foreach (OperationContainer operation in operations) { - foreach (Error error in exception.Errors) - { - error.Source.Pointer = $"/atomic:operations[{operationIndex}]" + error.Source.Pointer; - } + ValidateOperation(operation); - throw; + operationIndex++; } } - - private void ValidateOperation(OperationContainer operation) + catch (JsonApiException exception) { - if (operation.Kind == OperationKind.CreateResource) + foreach (ErrorObject error in exception.Errors) { - DeclareLocalId(operation.Resource); - } - else - { - AssertLocalIdIsAssigned(operation.Resource); + error.Source ??= new ErrorSource(); + error.Source.Pointer = $"/atomic:operations[{operationIndex}]{error.Source.Pointer}"; } - foreach (IIdentifiable secondaryResource in operation.GetSecondaryResources()) - { - AssertLocalIdIsAssigned(secondaryResource); - } + throw; + } + } - if (operation.Kind == OperationKind.CreateResource) - { - AssignLocalId(operation); - } + private void ValidateOperation(OperationContainer operation) + { + if (operation.Request.WriteOperation == WriteOperationKind.CreateResource) + { + DeclareLocalId(operation.Resource, operation.Request.PrimaryResourceType!); + } + else + { + AssertLocalIdIsAssigned(operation.Resource); } - private void DeclareLocalId(IIdentifiable resource) + foreach (IIdentifiable secondaryResource in operation.GetSecondaryResources()) { - if (resource.LocalId != null) - { - ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(resource.GetType()); - _localIdTracker.Declare(resource.LocalId, resourceContext.PublicName); - } + AssertLocalIdIsAssigned(secondaryResource); } - private void AssignLocalId(OperationContainer operation) + if (operation.Request.WriteOperation == WriteOperationKind.CreateResource) { - if (operation.Resource.LocalId != null) - { - ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(operation.Resource.GetType()); + AssignLocalId(operation, operation.Request.PrimaryResourceType!); + } + } - _localIdTracker.Assign(operation.Resource.LocalId, resourceContext.PublicName, "placeholder"); - } + private void DeclareLocalId(IIdentifiable resource, ResourceType resourceType) + { + if (resource.LocalId != null) + { + _localIdTracker.Declare(resource.LocalId, resourceType); } + } - private void AssertLocalIdIsAssigned(IIdentifiable resource) + private void AssignLocalId(OperationContainer operation, ResourceType resourceType) + { + if (operation.Resource.LocalId != null) { - if (resource.LocalId != null) - { - ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(resource.GetType()); - _localIdTracker.GetValue(resource.LocalId, resourceContext.PublicName); - } + _localIdTracker.Assign(operation.Resource.LocalId, resourceType, "placeholder"); + } + } + + private void AssertLocalIdIsAssigned(IIdentifiable resource) + { + if (resource.LocalId != null) + { + ResourceType resourceType = _resourceGraph.GetResourceType(resource.GetClrType()); + _localIdTracker.GetValue(resource.LocalId, resourceType); } } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/MissingTransactionFactory.cs b/src/JsonApiDotNetCore/AtomicOperations/MissingTransactionFactory.cs index 75c327c0f2..450636481f 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/MissingTransactionFactory.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/MissingTransactionFactory.cs @@ -1,20 +1,15 @@ -using System; -using System.Threading; -using System.Threading.Tasks; +namespace JsonApiDotNetCore.AtomicOperations; -namespace JsonApiDotNetCore.AtomicOperations +/// +/// A transaction factory that throws when used in an atomic:operations request, because no transaction support is available. +/// +public sealed class MissingTransactionFactory : IOperationsTransactionFactory { - /// - /// A transaction factory that throws when used in an atomic:operations request, because no transaction support is available. - /// - public sealed class MissingTransactionFactory : IOperationsTransactionFactory + /// + public Task BeginTransactionAsync(CancellationToken cancellationToken) { - /// - public Task BeginTransactionAsync(CancellationToken cancellationToken) - { - // When using a data store other than Entity Framework Core, replace this type with your custom implementation - // by overwriting the IoC container registration. - throw new NotImplementedException("No transaction support is available."); - } + // When using a data store other than Entity Framework Core, replace this type with your custom implementation + // by overwriting the IoC container registration. + throw new NotImplementedException("No transaction support is available."); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs b/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs index f387316720..a6dc2051ec 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationProcessorAccessor.cs @@ -1,6 +1,3 @@ -using System; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.AtomicOperations.Processors; using JsonApiDotNetCore.Configuration; @@ -8,75 +5,52 @@ using JsonApiDotNetCore.Resources; using Microsoft.Extensions.DependencyInjection; -namespace JsonApiDotNetCore.AtomicOperations +namespace JsonApiDotNetCore.AtomicOperations; + +/// +[PublicAPI] +public class OperationProcessorAccessor : IOperationProcessorAccessor { - /// - [PublicAPI] - public class OperationProcessorAccessor : IOperationProcessorAccessor + private readonly IServiceProvider _serviceProvider; + + public OperationProcessorAccessor(IServiceProvider serviceProvider) { - private readonly IResourceContextProvider _resourceContextProvider; - private readonly IServiceProvider _serviceProvider; + ArgumentNullException.ThrowIfNull(serviceProvider); - public OperationProcessorAccessor(IResourceContextProvider resourceContextProvider, IServiceProvider serviceProvider) - { - ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider)); - ArgumentGuard.NotNull(serviceProvider, nameof(serviceProvider)); + _serviceProvider = serviceProvider; + } - _resourceContextProvider = resourceContextProvider; - _serviceProvider = serviceProvider; - } + /// + public Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(operation); - /// - public Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) - { - ArgumentGuard.NotNull(operation, nameof(operation)); + IOperationProcessor processor = ResolveProcessor(operation); + return processor.ProcessAsync(operation, cancellationToken); + } - IOperationProcessor processor = ResolveProcessor(operation); - return processor.ProcessAsync(operation, cancellationToken); - } + protected virtual IOperationProcessor ResolveProcessor(OperationContainer operation) + { + ArgumentNullException.ThrowIfNull(operation); - protected virtual IOperationProcessor ResolveProcessor(OperationContainer operation) - { - Type processorInterface = GetProcessorInterface(operation.Kind); - ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(operation.Resource.GetType()); + Type processorInterface = GetProcessorInterface(operation.Request.WriteOperation!.Value); + ResourceType resourceType = operation.Request.PrimaryResourceType!; - Type processorType = processorInterface.MakeGenericType(resourceContext.ResourceType, resourceContext.IdentityType); - return (IOperationProcessor)_serviceProvider.GetRequiredService(processorType); - } + Type processorType = processorInterface.MakeGenericType(resourceType.ClrType, resourceType.IdentityClrType); + return (IOperationProcessor)_serviceProvider.GetRequiredService(processorType); + } - private static Type GetProcessorInterface(OperationKind kind) + private static Type GetProcessorInterface(WriteOperationKind writeOperation) + { + return writeOperation switch { - switch (kind) - { - case OperationKind.CreateResource: - { - return typeof(ICreateProcessor<,>); - } - case OperationKind.UpdateResource: - { - return typeof(IUpdateProcessor<,>); - } - case OperationKind.DeleteResource: - { - return typeof(IDeleteProcessor<,>); - } - case OperationKind.SetRelationship: - { - return typeof(ISetRelationshipProcessor<,>); - } - case OperationKind.AddToRelationship: - { - return typeof(IAddToRelationshipProcessor<,>); - } - case OperationKind.RemoveFromRelationship: - { - return typeof(IRemoveFromRelationshipProcessor<,>); - } - default: - { - throw new NotSupportedException($"Unknown operation kind '{kind}'."); - } - } - } + 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 1fb60d985e..f3d0b22256 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/OperationsProcessor.cs @@ -1,154 +1,151 @@ -using System; -using System.Collections.Generic; -using System.Net; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.AtomicOperations +namespace JsonApiDotNetCore.AtomicOperations; + +/// +[PublicAPI] +public class OperationsProcessor : IOperationsProcessor { + private readonly IOperationProcessorAccessor _operationProcessorAccessor; + private readonly IOperationsTransactionFactory _operationsTransactionFactory; + private readonly ILocalIdTracker _localIdTracker; + private readonly IResourceGraph _resourceGraph; + private readonly IJsonApiRequest _request; + private readonly ITargetedFields _targetedFields; + private readonly ISparseFieldSetCache _sparseFieldSetCache; + private readonly LocalIdValidator _localIdValidator; + + public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccessor, IOperationsTransactionFactory operationsTransactionFactory, + ILocalIdTracker localIdTracker, IResourceGraph resourceGraph, IJsonApiRequest request, ITargetedFields targetedFields, + ISparseFieldSetCache 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; + _localIdTracker = localIdTracker; + _resourceGraph = resourceGraph; + _request = request; + _targetedFields = targetedFields; + _sparseFieldSetCache = sparseFieldSetCache; + _localIdValidator = new LocalIdValidator(_localIdTracker, _resourceGraph); + } + /// - [PublicAPI] - public class OperationsProcessor : IOperationsProcessor + public virtual async Task> ProcessAsync(IList operations, CancellationToken cancellationToken) { - private readonly IOperationProcessorAccessor _operationProcessorAccessor; - private readonly IOperationsTransactionFactory _operationsTransactionFactory; - private readonly ILocalIdTracker _localIdTracker; - private readonly IResourceContextProvider _resourceContextProvider; - private readonly IJsonApiRequest _request; - private readonly ITargetedFields _targetedFields; - private readonly LocalIdValidator _localIdValidator; - - public OperationsProcessor(IOperationProcessorAccessor operationProcessorAccessor, IOperationsTransactionFactory operationsTransactionFactory, - ILocalIdTracker localIdTracker, IResourceContextProvider resourceContextProvider, IJsonApiRequest request, ITargetedFields targetedFields) - { - ArgumentGuard.NotNull(operationProcessorAccessor, nameof(operationProcessorAccessor)); - ArgumentGuard.NotNull(operationsTransactionFactory, nameof(operationsTransactionFactory)); - ArgumentGuard.NotNull(localIdTracker, nameof(localIdTracker)); - ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider)); - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); - - _operationProcessorAccessor = operationProcessorAccessor; - _operationsTransactionFactory = operationsTransactionFactory; - _localIdTracker = localIdTracker; - _resourceContextProvider = resourceContextProvider; - _request = request; - _targetedFields = targetedFields; - _localIdValidator = new LocalIdValidator(_localIdTracker, _resourceContextProvider); - } + ArgumentNullException.ThrowIfNull(operations); - /// - public virtual async Task> ProcessAsync(IList operations, CancellationToken cancellationToken) - { - ArgumentGuard.NotNull(operations, nameof(operations)); + _localIdValidator.Validate(operations); + _localIdTracker.Reset(); - _localIdValidator.Validate(operations); - _localIdTracker.Reset(); + List results = []; - var results = new List(); + await using IOperationsTransaction transaction = await _operationsTransactionFactory.BeginTransactionAsync(cancellationToken); - await using IOperationsTransaction transaction = await _operationsTransactionFactory.BeginTransactionAsync(cancellationToken); + try + { + using IDisposable _ = new RevertRequestStateOnDispose(_request, _targetedFields); - try + foreach (OperationContainer operation in operations) { - foreach (OperationContainer operation in operations) - { - operation.SetTransactionId(transaction.TransactionId); + operation.SetTransactionId(transaction.TransactionId); - await transaction.BeforeProcessOperationAsync(cancellationToken); + await transaction.BeforeProcessOperationAsync(cancellationToken); - OperationContainer result = await ProcessOperationAsync(operation, cancellationToken); - results.Add(result); + OperationContainer? result = await ProcessOperationAsync(operation, cancellationToken); + results.Add(result); - await transaction.AfterProcessOperationAsync(cancellationToken); - } + await transaction.AfterProcessOperationAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); + _sparseFieldSetCache.Reset(); } - catch (OperationCanceledException) + + await transaction.CommitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (JsonApiException exception) + { + foreach (ErrorObject error in exception.Errors) { - throw; + error.Source ??= new ErrorSource(); + error.Source.Pointer = $"/atomic:operations[{results.Count}]{error.Source.Pointer}"; } - catch (JsonApiException exception) - { - foreach (Error error in exception.Errors) - { - error.Source.Pointer = $"/atomic:operations[{results.Count}]" + error.Source.Pointer; - } - throw; - } + throw; + } #pragma warning disable AV1210 // Catch a specific exception instead of Exception, SystemException or ApplicationException - catch (Exception exception) + catch (Exception exception) #pragma warning restore AV1210 // Catch a specific exception instead of Exception, SystemException or ApplicationException - { - throw new JsonApiException(new Error(HttpStatusCode.InternalServerError) - { - Title = "An unhandled error occurred while processing an operation in this request.", - Detail = exception.Message, - Source = - { - Pointer = $"/atomic:operations[{results.Count}]" - } - }, exception); - } - - return results; + { + throw new FailedOperationException(results.Count, exception); } - protected virtual async Task ProcessOperationAsync(OperationContainer operation, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); + return results; + } + + protected virtual async Task ProcessOperationAsync(OperationContainer operation, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(operation); - TrackLocalIdsForOperation(operation); + cancellationToken.ThrowIfCancellationRequested(); - _targetedFields.Attributes = operation.TargetedFields.Attributes; - _targetedFields.Relationships = operation.TargetedFields.Relationships; + TrackLocalIdsForOperation(operation); - _request.CopyFrom(operation.Request); + _targetedFields.CopyFrom(operation.TargetedFields); + _request.CopyFrom(operation.Request); - return await _operationProcessorAccessor.ProcessAsync(operation, cancellationToken); - } + return await _operationProcessorAccessor.ProcessAsync(operation, cancellationToken); + } - protected void TrackLocalIdsForOperation(OperationContainer operation) + protected void TrackLocalIdsForOperation(OperationContainer operation) + { + ArgumentNullException.ThrowIfNull(operation); + + if (operation.Request.WriteOperation == WriteOperationKind.CreateResource) { - if (operation.Kind == OperationKind.CreateResource) - { - DeclareLocalId(operation.Resource); - } - else - { - AssignStringId(operation.Resource); - } + DeclareLocalId(operation.Resource, operation.Request.PrimaryResourceType!); + } + else + { + AssignStringId(operation.Resource); + } - foreach (IIdentifiable secondaryResource in operation.GetSecondaryResources()) - { - AssignStringId(secondaryResource); - } + foreach (IIdentifiable secondaryResource in operation.GetSecondaryResources()) + { + AssignStringId(secondaryResource); } + } - private void DeclareLocalId(IIdentifiable resource) + private void DeclareLocalId(IIdentifiable resource, ResourceType resourceType) + { + if (resource.LocalId != null) { - if (resource.LocalId != null) - { - ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(resource.GetType()); - _localIdTracker.Declare(resource.LocalId, resourceContext.PublicName); - } + _localIdTracker.Declare(resource.LocalId, resourceType); } + } - private void AssignStringId(IIdentifiable resource) + private void AssignStringId(IIdentifiable resource) + { + if (resource.LocalId != null) { - if (resource.LocalId != null) - { - ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(resource.GetType()); - resource.StringId = _localIdTracker.GetValue(resource.LocalId, resourceContext.PublicName); - } + ResourceType resourceType = _resourceGraph.GetResourceType(resource.GetClrType()); + resource.StringId = _localIdTracker.GetValue(resource.LocalId, resourceType); } } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs index 127316556d..e84756120a 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/AddToRelationshipProcessor.cs @@ -1,37 +1,33 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; -namespace JsonApiDotNetCore.AtomicOperations.Processors +namespace JsonApiDotNetCore.AtomicOperations.Processors; + +/// +[PublicAPI] +public class AddToRelationshipProcessor : IAddToRelationshipProcessor + where TResource : class, IIdentifiable { - /// - [PublicAPI] - public class AddToRelationshipProcessor : IAddToRelationshipProcessor - where TResource : class, IIdentifiable - { - private readonly IAddToRelationshipService _service; + private readonly IAddToRelationshipService _service; - public AddToRelationshipProcessor(IAddToRelationshipService service) - { - ArgumentGuard.NotNull(service, nameof(service)); + public AddToRelationshipProcessor(IAddToRelationshipService service) + { + ArgumentNullException.ThrowIfNull(service); - _service = service; - } + _service = service; + } - /// - public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) - { - ArgumentGuard.NotNull(operation, nameof(operation)); + /// + public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(operation); - var primaryId = (TId)operation.Resource.GetTypedId(); - ISet secondaryResourceIds = operation.GetSecondaryResources(); + var leftId = (TId)operation.Resource.GetTypedId(); + ISet rightResourceIds = operation.GetSecondaryResources(); - await _service.AddToToManyRelationshipAsync(primaryId, operation.Request.Relationship.PublicName, secondaryResourceIds, cancellationToken); + await _service.AddToToManyRelationshipAsync(leftId!, operation.Request.Relationship!.PublicName, rightResourceIds, cancellationToken); - return null; - } + return null; } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs index 8a8bdef8ad..c7bf3c9b77 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/CreateProcessor.cs @@ -1,48 +1,39 @@ -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; -namespace JsonApiDotNetCore.AtomicOperations.Processors -{ - /// - [PublicAPI] - public class CreateProcessor : ICreateProcessor - where TResource : class, IIdentifiable - { - private readonly ICreateService _service; - private readonly ILocalIdTracker _localIdTracker; - private readonly IResourceContextProvider _resourceContextProvider; +namespace JsonApiDotNetCore.AtomicOperations.Processors; - public CreateProcessor(ICreateService service, ILocalIdTracker localIdTracker, IResourceContextProvider resourceContextProvider) - { - ArgumentGuard.NotNull(service, nameof(service)); - ArgumentGuard.NotNull(localIdTracker, nameof(localIdTracker)); - ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider)); - - _service = service; - _localIdTracker = localIdTracker; - _resourceContextProvider = resourceContextProvider; - } +/// +[PublicAPI] +public class CreateProcessor : ICreateProcessor + where TResource : class, IIdentifiable +{ + private readonly ICreateService _service; + private readonly ILocalIdTracker _localIdTracker; - /// - public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) - { - ArgumentGuard.NotNull(operation, nameof(operation)); + public CreateProcessor(ICreateService service, ILocalIdTracker localIdTracker) + { + ArgumentNullException.ThrowIfNull(service); + ArgumentNullException.ThrowIfNull(localIdTracker); - TResource newResource = await _service.CreateAsync((TResource)operation.Resource, cancellationToken); + _service = service; + _localIdTracker = localIdTracker; + } - if (operation.Resource.LocalId != null) - { - string serverId = newResource != null ? newResource.StringId : operation.Resource.StringId; - ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(); + /// + public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(operation); - _localIdTracker.Assign(operation.Resource.LocalId, resourceContext.PublicName, serverId); - } + TResource? newResource = await _service.CreateAsync((TResource)operation.Resource, cancellationToken); - return newResource == null ? null : operation.WithResource(newResource); + if (operation.Resource.LocalId != null) + { + string serverId = newResource != null ? newResource.StringId! : operation.Resource.StringId!; + _localIdTracker.Assign(operation.Resource.LocalId, operation.Request.PrimaryResourceType!, serverId); } + + return newResource == null ? null : operation.WithResource(newResource); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs index 929ffe73a9..5709188a8c 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/DeleteProcessor.cs @@ -1,34 +1,31 @@ -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; -namespace JsonApiDotNetCore.AtomicOperations.Processors +namespace JsonApiDotNetCore.AtomicOperations.Processors; + +/// +[PublicAPI] +public class DeleteProcessor : IDeleteProcessor + where TResource : class, IIdentifiable { - /// - [PublicAPI] - public class DeleteProcessor : IDeleteProcessor - where TResource : class, IIdentifiable - { - private readonly IDeleteService _service; + private readonly IDeleteService _service; - public DeleteProcessor(IDeleteService service) - { - ArgumentGuard.NotNull(service, nameof(service)); + public DeleteProcessor(IDeleteService service) + { + ArgumentNullException.ThrowIfNull(service); - _service = service; - } + _service = service; + } - /// - public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) - { - ArgumentGuard.NotNull(operation, nameof(operation)); + /// + public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(operation); - var id = (TId)operation.Resource.GetTypedId(); - await _service.DeleteAsync(id, cancellationToken); + var id = (TId)operation.Resource.GetTypedId(); + await _service.DeleteAsync(id!, cancellationToken); - return null; - } + return null; } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IAddToRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IAddToRelationshipProcessor.cs index 2f7c10a3f7..91d23e3358 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IAddToRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IAddToRelationshipProcessor.cs @@ -3,20 +3,17 @@ // ReSharper disable UnusedTypeParameter -namespace JsonApiDotNetCore.AtomicOperations.Processors -{ - /// - /// Processes a single operation to add resources to a to-many relationship. - /// - /// - /// The resource type. - /// - /// - /// The resource identifier type. - /// - [PublicAPI] - public interface IAddToRelationshipProcessor : IOperationProcessor - where TResource : class, IIdentifiable - { - } -} +namespace JsonApiDotNetCore.AtomicOperations.Processors; + +/// +/// Processes a single operation to add resources to a to-many relationship. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +[PublicAPI] +public interface IAddToRelationshipProcessor : IOperationProcessor + where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateProcessor.cs index 0f747a9dd0..6cc04043f3 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/ICreateProcessor.cs @@ -3,20 +3,17 @@ // ReSharper disable UnusedTypeParameter -namespace JsonApiDotNetCore.AtomicOperations.Processors -{ - /// - /// Processes a single operation to create a new resource with attributes, relationships or both. - /// - /// - /// The resource type. - /// - /// - /// The resource identifier type. - /// - [PublicAPI] - public interface ICreateProcessor : IOperationProcessor - where TResource : class, IIdentifiable - { - } -} +namespace JsonApiDotNetCore.AtomicOperations.Processors; + +/// +/// Processes a single operation to create a new resource with attributes, relationships or both. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +[PublicAPI] +public interface ICreateProcessor : IOperationProcessor + where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IDeleteProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IDeleteProcessor.cs index 4e5206054d..42f5f71c14 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IDeleteProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IDeleteProcessor.cs @@ -3,20 +3,17 @@ // ReSharper disable UnusedTypeParameter -namespace JsonApiDotNetCore.AtomicOperations.Processors -{ - /// - /// Processes a single operation to delete an existing resource. - /// - /// - /// The resource type. - /// - /// - /// The resource identifier type. - /// - [PublicAPI] - public interface IDeleteProcessor : IOperationProcessor - where TResource : class, IIdentifiable - { - } -} +namespace JsonApiDotNetCore.AtomicOperations.Processors; + +/// +/// Processes a single operation to delete an existing resource. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +[PublicAPI] +public interface IDeleteProcessor : IOperationProcessor + where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IOperationProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IOperationProcessor.cs index 6b51694260..4df297469e 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IOperationProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IOperationProcessor.cs @@ -1,17 +1,14 @@ -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.AtomicOperations.Processors +namespace JsonApiDotNetCore.AtomicOperations.Processors; + +/// +/// Processes a single entry in a list of operations. +/// +public interface IOperationProcessor { /// - /// Processes a single entry in a list of operations. + /// Processes the specified operation. /// - public interface IOperationProcessor - { - /// - /// Processes the specified operation. - /// - Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken); - } + Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveFromRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveFromRelationshipProcessor.cs index 02ce98d21d..2dc7bdb17d 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveFromRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IRemoveFromRelationshipProcessor.cs @@ -3,16 +3,13 @@ // ReSharper disable UnusedTypeParameter -namespace JsonApiDotNetCore.AtomicOperations.Processors -{ - /// - /// Processes a single operation to remove resources from a to-many relationship. - /// - /// - /// - [PublicAPI] - public interface IRemoveFromRelationshipProcessor : IOperationProcessor - where TResource : class, IIdentifiable - { - } -} +namespace JsonApiDotNetCore.AtomicOperations.Processors; + +/// +/// Processes a single operation to remove resources from a to-many relationship. +/// +/// +/// +[PublicAPI] +public interface IRemoveFromRelationshipProcessor : IOperationProcessor + where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/ISetRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/ISetRelationshipProcessor.cs index 8dafc839a4..7928aa76b0 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/ISetRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/ISetRelationshipProcessor.cs @@ -3,20 +3,17 @@ // ReSharper disable UnusedTypeParameter -namespace JsonApiDotNetCore.AtomicOperations.Processors -{ - /// - /// Processes a single operation to perform a complete replacement of a relationship on an existing resource. - /// - /// - /// The resource type. - /// - /// - /// The resource identifier type. - /// - [PublicAPI] - public interface ISetRelationshipProcessor : IOperationProcessor - where TResource : class, IIdentifiable - { - } -} +namespace JsonApiDotNetCore.AtomicOperations.Processors; + +/// +/// Processes a single operation to perform a complete replacement of a relationship on an existing resource. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +[PublicAPI] +public interface ISetRelationshipProcessor : IOperationProcessor + where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateProcessor.cs index 48847f6ddb..77b83f65f7 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/IUpdateProcessor.cs @@ -3,21 +3,18 @@ // ReSharper disable UnusedTypeParameter -namespace JsonApiDotNetCore.AtomicOperations.Processors -{ - /// - /// Processes a single operation 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. - /// - /// - /// The resource type. - /// - /// - /// The resource identifier type. - /// - [PublicAPI] - public interface IUpdateProcessor : IOperationProcessor - where TResource : class, IIdentifiable - { - } -} +namespace JsonApiDotNetCore.AtomicOperations.Processors; + +/// +/// Processes a single operation 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. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +[PublicAPI] +public interface IUpdateProcessor : IOperationProcessor + where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs index b41169510d..81c4eb93ee 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/RemoveFromRelationshipProcessor.cs @@ -1,37 +1,33 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; -namespace JsonApiDotNetCore.AtomicOperations.Processors +namespace JsonApiDotNetCore.AtomicOperations.Processors; + +/// +[PublicAPI] +public class RemoveFromRelationshipProcessor : IRemoveFromRelationshipProcessor + where TResource : class, IIdentifiable { - /// - [PublicAPI] - public class RemoveFromRelationshipProcessor : IRemoveFromRelationshipProcessor - where TResource : class, IIdentifiable - { - private readonly IRemoveFromRelationshipService _service; + private readonly IRemoveFromRelationshipService _service; - public RemoveFromRelationshipProcessor(IRemoveFromRelationshipService service) - { - ArgumentGuard.NotNull(service, nameof(service)); + public RemoveFromRelationshipProcessor(IRemoveFromRelationshipService service) + { + ArgumentNullException.ThrowIfNull(service); - _service = service; - } + _service = service; + } - /// - public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) - { - ArgumentGuard.NotNull(operation, nameof(operation)); + /// + public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(operation); - var primaryId = (TId)operation.Resource.GetTypedId(); - ISet secondaryResourceIds = operation.GetSecondaryResources(); + var leftId = (TId)operation.Resource.GetTypedId(); + ISet rightResourceIds = operation.GetSecondaryResources(); - await _service.RemoveFromToManyRelationshipAsync(primaryId, operation.Request.Relationship.PublicName, secondaryResourceIds, cancellationToken); + await _service.RemoveFromToManyRelationshipAsync(leftId!, operation.Request.Relationship!.PublicName, rightResourceIds, cancellationToken); - return null; - } + return null; } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs index a69eabcc57..913068a26c 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/SetRelationshipProcessor.cs @@ -1,54 +1,48 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Services; -namespace JsonApiDotNetCore.AtomicOperations.Processors +namespace JsonApiDotNetCore.AtomicOperations.Processors; + +/// +[PublicAPI] +public class SetRelationshipProcessor : ISetRelationshipProcessor + where TResource : class, IIdentifiable { - /// - [PublicAPI] - public class SetRelationshipProcessor : ISetRelationshipProcessor - where TResource : class, IIdentifiable + private readonly ISetRelationshipService _service; + + public SetRelationshipProcessor(ISetRelationshipService service) { - private readonly CollectionConverter _collectionConverter = new CollectionConverter(); - private readonly ISetRelationshipService _service; + ArgumentNullException.ThrowIfNull(service); - public SetRelationshipProcessor(ISetRelationshipService service) - { - ArgumentGuard.NotNull(service, nameof(service)); + _service = service; + } - _service = service; - } + /// + public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(operation); - /// - public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) - { - ArgumentGuard.NotNull(operation, nameof(operation)); + var leftId = (TId)operation.Resource.GetTypedId(); + object? rightValue = GetRelationshipRightValue(operation); - var primaryId = (TId)operation.Resource.GetTypedId(); - object rightValue = GetRelationshipRightValue(operation); + await _service.SetRelationshipAsync(leftId!, operation.Request.Relationship!.PublicName, rightValue, cancellationToken); - await _service.SetRelationshipAsync(primaryId, operation.Request.Relationship.PublicName, rightValue, cancellationToken); + return null; + } - return null; - } + private object? GetRelationshipRightValue(OperationContainer operation) + { + RelationshipAttribute relationship = operation.Request.Relationship!; + object? rightValue = relationship.GetValue(operation.Resource); - private object GetRelationshipRightValue(OperationContainer operation) + if (relationship is HasManyAttribute) { - RelationshipAttribute relationship = operation.Request.Relationship; - object rightValue = relationship.GetValue(operation.Resource); - - if (relationship is HasManyAttribute) - { - ICollection rightResources = _collectionConverter.ExtractResources(rightValue); - return rightResources.ToHashSet(IdentifiableComparer.Instance); - } - - return rightValue; + IReadOnlyCollection rightResources = CollectionConverter.Instance.ExtractResources(rightValue); + return rightResources.ToHashSet(IdentifiableComparer.Instance); } + + return rightValue; } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs index 151d91adfe..cf66c70462 100644 --- a/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs +++ b/src/JsonApiDotNetCore/AtomicOperations/Processors/UpdateProcessor.cs @@ -1,34 +1,31 @@ -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; -namespace JsonApiDotNetCore.AtomicOperations.Processors +namespace JsonApiDotNetCore.AtomicOperations.Processors; + +/// +[PublicAPI] +public class UpdateProcessor : IUpdateProcessor + where TResource : class, IIdentifiable { - /// - [PublicAPI] - public class UpdateProcessor : IUpdateProcessor - where TResource : class, IIdentifiable - { - private readonly IUpdateService _service; + private readonly IUpdateService _service; - public UpdateProcessor(IUpdateService service) - { - ArgumentGuard.NotNull(service, nameof(service)); + public UpdateProcessor(IUpdateService service) + { + ArgumentNullException.ThrowIfNull(service); - _service = service; - } + _service = service; + } - /// - public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) - { - ArgumentGuard.NotNull(operation, nameof(operation)); + /// + public virtual async Task ProcessAsync(OperationContainer operation, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(operation); - var resource = (TResource)operation.Resource; - TResource updated = await _service.UpdateAsync(resource.Id, resource, cancellationToken); + var resource = (TResource)operation.Resource; + TResource? updated = await _service.UpdateAsync(resource.Id!, resource, cancellationToken); - return updated == null ? null : operation.WithResource(updated); - } + return updated == null ? null : operation.WithResource(updated); } } diff --git a/src/JsonApiDotNetCore/AtomicOperations/RevertRequestStateOnDispose.cs b/src/JsonApiDotNetCore/AtomicOperations/RevertRequestStateOnDispose.cs new file mode 100644 index 0000000000..82b0641c34 --- /dev/null +++ b/src/JsonApiDotNetCore/AtomicOperations/RevertRequestStateOnDispose.cs @@ -0,0 +1,36 @@ +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.AtomicOperations; + +/// +/// Copies the current request state into a backup, which is restored on dispose. +/// +internal sealed class RevertRequestStateOnDispose : IDisposable +{ + private readonly IJsonApiRequest _sourceRequest; + private readonly ITargetedFields? _sourceTargetedFields; + + private readonly JsonApiRequest _backupRequest = new(); + private readonly TargetedFields _backupTargetedFields = new(); + + public RevertRequestStateOnDispose(IJsonApiRequest request, ITargetedFields? targetedFields) + { + ArgumentNullException.ThrowIfNull(request); + + _sourceRequest = request; + _backupRequest.CopyFrom(request); + + if (targetedFields != null) + { + _sourceTargetedFields = targetedFields; + _backupTargetedFields.CopyFrom(targetedFields); + } + } + + public void Dispose() + { + _sourceRequest.CopyFrom(_backupRequest); + _sourceTargetedFields?.CopyFrom(_backupTargetedFields); + } +} diff --git a/src/JsonApiDotNetCore/CollectionConverter.cs b/src/JsonApiDotNetCore/CollectionConverter.cs deleted file mode 100644 index a79757ceac..0000000000 --- a/src/JsonApiDotNetCore/CollectionConverter.cs +++ /dev/null @@ -1,126 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCore -{ - internal class CollectionConverter - { - private static readonly Type[] HashSetCompatibleCollectionTypes = - { - typeof(HashSet<>), - typeof(ICollection<>), - typeof(ISet<>), - typeof(IEnumerable<>), - typeof(IReadOnlyCollection<>) - }; - - /// - /// Creates a collection instance based on the specified collection type and copies the specified elements into it. - /// - /// - /// Source to copy from. - /// - /// - /// Target collection type, for example: typeof(List{Article}) or typeof(ISet{Person}). - /// - public IEnumerable CopyToTypedCollection(IEnumerable source, Type collectionType) - { - ArgumentGuard.NotNull(source, nameof(source)); - ArgumentGuard.NotNull(collectionType, nameof(collectionType)); - - Type concreteCollectionType = ToConcreteCollectionType(collectionType); - dynamic concreteCollectionInstance = Activator.CreateInstance(concreteCollectionType); - - foreach (object item in source) - { - concreteCollectionInstance!.Add((dynamic)item); - } - - return concreteCollectionInstance; - } - - /// - /// Returns a compatible collection type that can be instantiated, for example IList{Article} -> List{Article} or ISet{Article} -> HashSet{Article} - /// - public Type ToConcreteCollectionType(Type collectionType) - { - if (collectionType.IsInterface && collectionType.IsGenericType) - { - Type genericTypeDefinition = collectionType.GetGenericTypeDefinition(); - - if (genericTypeDefinition == typeof(ICollection<>) || genericTypeDefinition == typeof(ISet<>) || - genericTypeDefinition == typeof(IEnumerable<>) || genericTypeDefinition == typeof(IReadOnlyCollection<>)) - { - return typeof(HashSet<>).MakeGenericType(collectionType.GenericTypeArguments[0]); - } - - if (genericTypeDefinition == typeof(IList<>) || genericTypeDefinition == typeof(IReadOnlyList<>)) - { - return typeof(List<>).MakeGenericType(collectionType.GenericTypeArguments[0]); - } - } - - return collectionType; - } - - /// - /// Returns a collection that contains zero, one or multiple resources, depending on the specified value. - /// - public ICollection ExtractResources(object value) - { - if (value is ICollection resourceCollection) - { - return resourceCollection; - } - - if (value is IEnumerable resources) - { - return resources.ToList(); - } - - if (value is IIdentifiable resource) - { - return resource.AsArray(); - } - - return Array.Empty(); - } - - /// - /// Returns the element type if the specified type is a generic collection, for example: IList{string} -> string or IList -> null. - /// - public Type TryGetCollectionElementType(Type type) - { - if (type != null) - { - if (type.IsGenericType && type.GenericTypeArguments.Length == 1) - { - if (type.IsOrImplementsInterface(typeof(IEnumerable))) - { - return type.GenericTypeArguments[0]; - } - } - } - - return null; - } - - /// - /// Indicates whether a instance can be assigned to the specified type, for example IList{Article} -> false or ISet{Article} -> - /// true. - /// - public bool TypeCanContainHashSet(Type collectionType) - { - if (collectionType.IsGenericType) - { - Type openCollectionType = collectionType.GetGenericTypeDefinition(); - return HashSetCompatibleCollectionTypes.Contains(openCollectionType); - } - - return false; - } - } -} diff --git a/src/JsonApiDotNetCore/CollectionExtensions.cs b/src/JsonApiDotNetCore/CollectionExtensions.cs index 68997f60b3..ca46953bfc 100644 --- a/src/JsonApiDotNetCore/CollectionExtensions.cs +++ b/src/JsonApiDotNetCore/CollectionExtensions.cs @@ -1,38 +1,109 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using JetBrains.Annotations; +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Contracts; -namespace JsonApiDotNetCore +namespace JsonApiDotNetCore; + +internal static class CollectionExtensions { - internal static class CollectionExtensions + [Pure] + public static bool IsNullOrEmpty([NotNullWhen(false)] this IEnumerable? source) + { + if (source == null) + { + return true; + } + + 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) { - [Pure] - [ContractAnnotation("source: null => true")] - public static bool IsNullOrEmpty(this IEnumerable source) + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(match); + + for (int index = 0; index < source.Count; index++) { - if (source == null) + if (match(source[index])) { - return true; + return index; } + } + + return -1; + } + + public static IEnumerable ToEnumerable(this LinkedListNode? startNode) + { + LinkedListNode? current = startNode; - return !source.Any(); + while (current != null) + { + yield return current.Value; + + current = current.Next; } + } - public static int FindIndex(this IList source, Predicate match) + public static bool DictionaryEqual(this IReadOnlyDictionary? first, IReadOnlyDictionary? second, + IEqualityComparer? valueComparer = null) + { + if (ReferenceEquals(first, second)) { - ArgumentGuard.NotNull(source, nameof(source)); - ArgumentGuard.NotNull(match, nameof(match)); + return true; + } + + if (first == null || second == null) + { + return false; + } + + if (first.Count != second.Count) + { + return false; + } - for (int index = 0; index < source.Count; index++) + IEqualityComparer effectiveValueComparer = valueComparer ?? EqualityComparer.Default; + + foreach ((TKey firstKey, TValue firstValue) in first) + { + if (!second.TryGetValue(firstKey, out TValue? secondValue)) { - if (match(source[index])) - { - return index; - } + return false; } - return -1; + if (!effectiveValueComparer.Equals(firstValue, secondValue)) + { + return false; + } } + + return true; + } + + public static IEnumerable EmptyIfNull(this IEnumerable? source) + { + return source ?? Array.Empty(); + } + + public static IEnumerable WhereNotNull(this IEnumerable source) + { +#pragma warning disable AV1250 // Evaluate LINQ query before returning it + return source.Where(element => element is not null)!; +#pragma warning restore AV1250 // Evaluate LINQ query before returning it } } diff --git a/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs b/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs index 740cbdac0f..fb69fa5ae5 100644 --- a/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs +++ b/src/JsonApiDotNetCore/Configuration/ApplicationBuilderExtensions.cs @@ -1,48 +1,82 @@ +using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +public static class ApplicationBuilderExtensions { - public static class ApplicationBuilderExtensions + /// + /// Registers the JsonApiDotNetCore middleware. + /// + /// + /// The to add the middleware to. + /// + /// + /// The code below is the minimal that is required for proper activation, which should be added to your Startup.Configure method. + /// endpoints.MapControllers()); + /// ]]> + /// + public static void UseJsonApi(this IApplicationBuilder builder) { - /// - /// Registers the JsonApiDotNetCore middleware. - /// - /// - /// The to add the middleware to. - /// - /// - /// The code below is the minimal that is required for proper activation, which should be added to your Startup.Configure method. - /// endpoints.MapControllers()); - /// ]]> - /// - public static void UseJsonApi(this IApplicationBuilder builder) - { - ArgumentGuard.NotNull(builder, nameof(builder)); + ArgumentNullException.ThrowIfNull(builder); + AssertAspNetCoreOpenApiIsNotRegistered(builder.ApplicationServices); - using IServiceScope scope = builder.ApplicationServices.GetRequiredService().CreateScope(); + using (IServiceScope scope = builder.ApplicationServices.CreateScope()) + { var inverseNavigationResolver = scope.ServiceProvider.GetRequiredService(); inverseNavigationResolver.Resolve(); + } - var jsonApiApplicationBuilder = builder.ApplicationServices.GetRequiredService(); + var jsonApiApplicationBuilder = builder.ApplicationServices.GetRequiredService(); - jsonApiApplicationBuilder.ConfigureMvcOptions = options => - { - var inputFormatter = builder.ApplicationServices.GetRequiredService(); - options.InputFormatters.Insert(0, inputFormatter); + jsonApiApplicationBuilder.ConfigureMvcOptions = options => + { + var inputFormatter = builder.ApplicationServices.GetRequiredService(); + options.InputFormatters.Insert(0, inputFormatter); + + var outputFormatter = builder.ApplicationServices.GetRequiredService(); + options.OutputFormatters.Insert(0, outputFormatter); + + var routingConvention = builder.ApplicationServices.GetRequiredService(); + options.Conventions.Insert(0, routingConvention); + }; + + builder.UseMiddleware(); + } - var outputFormatter = builder.ApplicationServices.GetRequiredService(); - options.OutputFormatters.Insert(0, outputFormatter); + private static void AssertAspNetCoreOpenApiIsNotRegistered(IServiceProvider serviceProvider) + { + Type? optionsType = TryLoadOptionsType(); + + if (optionsType != null) + { + Type configureType = typeof(IConfigureOptions<>).MakeGenericType(optionsType); + object? configureInstance = serviceProvider.GetService(configureType); - var routingConvention = builder.ApplicationServices.GetRequiredService(); - options.Conventions.Insert(0, routingConvention); - }; + 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."); + } + } + } - builder.UseMiddleware(); + 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/GenericServiceFactory.cs b/src/JsonApiDotNetCore/Configuration/GenericServiceFactory.cs deleted file mode 100644 index 5806790fb5..0000000000 --- a/src/JsonApiDotNetCore/Configuration/GenericServiceFactory.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; - -namespace JsonApiDotNetCore.Configuration -{ - /// - public sealed class GenericServiceFactory : IGenericServiceFactory - { - private readonly IServiceProvider _serviceProvider; - - public GenericServiceFactory(IRequestScopedServiceProvider serviceProvider) - { - ArgumentGuard.NotNull(serviceProvider, nameof(serviceProvider)); - - _serviceProvider = serviceProvider; - } - - /// - public TInterface Get(Type openGenericType, Type resourceType) - { - ArgumentGuard.NotNull(openGenericType, nameof(openGenericType)); - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - - return GetInternal(openGenericType, resourceType); - } - - /// - public TInterface Get(Type openGenericType, Type resourceType, Type keyType) - { - ArgumentGuard.NotNull(openGenericType, nameof(openGenericType)); - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - ArgumentGuard.NotNull(keyType, nameof(keyType)); - - return GetInternal(openGenericType, resourceType, keyType); - } - - private TInterface GetInternal(Type openGenericType, params Type[] types) - { - Type concreteType = openGenericType.MakeGenericType(types); - - return (TInterface)_serviceProvider.GetService(concreteType); - } - } -} diff --git a/src/JsonApiDotNetCore/Configuration/IGenericServiceFactory.cs b/src/JsonApiDotNetCore/Configuration/IGenericServiceFactory.cs deleted file mode 100644 index a730642e71..0000000000 --- a/src/JsonApiDotNetCore/Configuration/IGenericServiceFactory.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Configuration -{ - /// - /// Represents the Service Locator design pattern. Used to obtain object instances for types are not known until runtime. This is only used by resource - /// hooks and subject to be removed in a future version. - /// - [PublicAPI] - public interface IGenericServiceFactory - { - /// - /// Constructs the generic type and locates the service, then casts to . - /// - /// - /// (typeof(GenericProcessor<>), typeof(TResource)); - /// ]]> - /// - TInterface Get(Type openGenericType, Type resourceType); - - /// - /// Constructs the generic type and locates the service, then casts to . - /// - /// - /// (typeof(GenericProcessor<>), typeof(TResource), typeof(TId)); - /// ]]> - /// - TInterface Get(Type openGenericType, Type resourceType, Type keyType); - } -} diff --git a/src/JsonApiDotNetCore/Configuration/IInverseNavigationResolver.cs b/src/JsonApiDotNetCore/Configuration/IInverseNavigationResolver.cs index 0a6efb3a28..0146386313 100644 --- a/src/JsonApiDotNetCore/Configuration/IInverseNavigationResolver.cs +++ b/src/JsonApiDotNetCore/Configuration/IInverseNavigationResolver.cs @@ -1,20 +1,18 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +/// +/// Responsible for populating . This service is instantiated in the configure phase of the +/// application. When using a data access layer different from Entity Framework Core, you will need to implement and register this service, or set +/// explicitly. +/// +[PublicAPI] +public interface IInverseNavigationResolver { /// - /// Responsible for populating . This service is instantiated in the configure phase of the - /// application. When using a data access layer different from EF Core, and when using ResourceHooks that depend on the inverse navigation property - /// (BeforeImplicitUpdateRelationship), you will need to override this service, or set - /// explicitly. + /// This method is called upon startup by JsonApiDotNetCore. It resolves inverse navigations. /// - [PublicAPI] - public interface IInverseNavigationResolver - { - /// - /// This method is called upon startup by JsonApiDotNetCore. It resolves inverse navigations. - /// - void Resolve(); - } + void Resolve(); } diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiApplicationBuilder.cs index 53b84e36d1..459e5be291 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiApplicationBuilder.cs @@ -1,10 +1,8 @@ -using System; using Microsoft.AspNetCore.Mvc; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +internal interface IJsonApiApplicationBuilder { - internal interface IJsonApiApplicationBuilder - { - public Action ConfigureMvcOptions { set; } - } + public Action? ConfigureMvcOptions { set; } } 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 f735e9c2ef..7141125e40 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs @@ -1,185 +1,210 @@ -using System; using System.Data; +using System.Text.Json; +using JetBrains.Annotations; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; -using Newtonsoft.Json; -using Newtonsoft.Json.Serialization; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +/// +/// Global options that configure the behavior of JsonApiDotNetCore. +/// +public interface IJsonApiOptions { /// - /// Global options that configure the behavior of JsonApiDotNetCore. - /// - public interface IJsonApiOptions - { - internal NamingStrategy SerializerNamingStrategy - { - get - { - var contractResolver = SerializerSettings.ContractResolver as DefaultContractResolver; - return contractResolver?.NamingStrategy ?? JsonApiOptions.DefaultNamingStrategy; - } - } - - /// - /// 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 . - /// - AttrCapabilities DefaultAttrCapabilities { get; } - - /// - /// Indicates whether responses should contain a jsonapi object that contains the highest JSON:API version supported. False by default. - /// - bool IncludeJsonApiVersion { get; } - - /// - /// Whether or not stack traces should be serialized in objects. False by default. - /// - bool IncludeExceptionStackTraceInErrors { get; } - - /// - /// Use relative links for all resources. False by default. - /// - /// - /// - /// options.UseRelativeLinks = true; - /// - /// - /// { - /// "type": "articles", - /// "id": "4309", - /// "relationships": { - /// "author": { - /// "links": { - /// "self": "/api/v1/articles/4309/relationships/author", - /// "related": "/api/v1/articles/4309/author" - /// } - /// } - /// } - /// } - /// - /// - bool UseRelativeLinks { get; } - - /// - /// Configures which links to show in the object. Defaults to . This - /// setting can be overruled per resource type by adding on the class definition of a resource. - /// - LinkTypes TopLevelLinks { get; } - - /// - /// Configures which links to show in the object. Defaults to . This - /// setting can be overruled per resource type by adding on the class definition of a resource. - /// - LinkTypes ResourceLinks { get; } - - /// - /// Configures which links to show in the object. Defaults to . This - /// setting can be overruled for all relationships per resource type by adding on the class definition of a - /// resource. This can be further overruled per relationship by setting . - /// - LinkTypes RelationshipLinks { get; } - - /// - /// Whether or not the total resource count should be included in all document-level meta objects. 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. - /// - PageSize DefaultPageSize { get; } - - /// - /// The maximum page size that can be used, or null for unconstrained (default). - /// - PageSize MaximumPageSize { get; } - - /// - /// The maximum page number that can be used, or null for unconstrained (default). - /// - PageNumber MaximumPageNumber { get; } - - /// - /// Whether or not to enable ASP.NET Core model state validation. False 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. - /// - bool AllowClientGeneratedIds { get; } - - /// - /// Whether or not resource hooks are enabled. This is currently an experimental feature and subject to change in future versions. Defaults to False. - /// - public bool EnableResourceHooks { get; } - - /// - /// Whether or not database values should be included by default for resource hooks. Ignored if EnableResourceHooks is set to false. False by default. - /// - bool LoadDatabaseValues { get; } - - /// - /// Whether or not to produce an error on unknown query string parameters. False by default. - /// - bool AllowUnknownQueryStringParameters { get; } - - /// - /// Determines whether legacy filter notation in query strings, such as =eq:, =like:, and =in: is enabled. False by default. - /// - bool EnableLegacyFilterNotation { get; } - - /// - /// Determines whether the serialization setting can be controlled using a query string - /// parameter. False by default. - /// - bool AllowQueryStringOverrideForSerializerNullValueHandling { get; } - - /// - /// Determines whether the serialization setting can be controlled using a query string - /// parameter. False by default. - /// - bool AllowQueryStringOverrideForSerializerDefaultValueHandling { get; } - - /// - /// Controls how many levels deep includes are allowed to be nested. For example, MaximumIncludeDepth=1 would allow ?include=articles but not - /// ?include=articles.revisions. null by default, which means unconstrained. - /// - int? MaximumIncludeDepth { get; } - - /// - /// Limits the maximum number of operations allowed per atomic:operations request. Defaults to 10. Set to null for unlimited. - /// - int? MaximumOperationsPerRequest { get; } - - /// - /// Enables to override the default isolation level for database transactions, enabling to balance between consistency and performance. Defaults to - /// null, which leaves this up to Entity Framework Core to choose (and then it varies per database provider). - /// - IsolationLevel? TransactionIsolationLevel { get; } - - /// - /// Specifies the settings that are used by the . Note that at some places a few settings are ignored, to ensure JSON:API - /// spec compliance. - /// - /// The next example changes the naming convention to kebab casing. - /// - /// - /// - JsonSerializerSettings SerializerSettings { get; } - } + /// The URL prefix to use for exposed endpoints. + /// + /// + /// + /// + string? Namespace { get; } + + /// + /// Specifies the default set of allowed capabilities on JSON:API attributes. Defaults to . This setting can be + /// overruled per attribute using . + /// + AttrCapabilities DefaultAttrCapabilities { get; } + + /// + /// Specifies the default set of allowed capabilities on JSON:API to-one relationships. Defaults to . 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 to include stack traces in responses. false by default. + /// + bool IncludeExceptionStackTraceInErrors { get; } + + /// + /// Whether to include the request body in responses when it is invalid. false by default. + /// + bool IncludeRequestBodyInErrors { get; } + + /// + /// Whether to use relative links for all resources. false by default. + /// + /// + /// + /// + /// + bool UseRelativeLinks { get; } + + /// + /// Configures which links to write in the object. Defaults to . This + /// setting can be overruled per resource type by adding on the class definition of a resource. + /// + LinkTypes TopLevelLinks { get; } + + /// + /// Configures which links to write in the object. Defaults to . This + /// setting can be overruled per resource type by adding on the class definition of a resource. + /// + LinkTypes ResourceLinks { get; } + + /// + /// Configures which links to write in the object. Defaults to . This + /// setting can be overruled for all relationships per resource type by adding on the class definition of a + /// resource. This can be further overruled per relationship by setting . + /// + LinkTypes RelationshipLinks { get; } + + /// + /// 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 pagination by default. + /// + PageSize? DefaultPageSize { get; } + + /// + /// The maximum page size that can be used, or null for unconstrained (default). + /// + PageSize? MaximumPageSize { get; } + + /// + /// The maximum page number that can be used, or null for unconstrained (default). + /// + PageNumber? MaximumPageNumber { get; } + + /// + /// Whether ASP.NET ModelState validation is enabled. true by default. + /// + bool ValidateModelState { get; } + + /// + /// 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 to produce an error on unknown query string parameters. false by default. + /// + bool AllowUnknownQueryStringParameters { get; } + + /// + /// 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. + /// + bool EnableLegacyFilterNotation { get; } + + /// + /// Controls how many levels deep includes are allowed to be nested. For example, MaximumIncludeDepth=1 would allow ?include=articles but not + /// ?include=articles.revisions. null by default, which means unconstrained. + /// + int? MaximumIncludeDepth { get; } + + /// + /// Limits the maximum number of operations allowed per atomic:operations request. Defaults to 10. Set to null for unlimited. + /// + int? MaximumOperationsPerRequest { get; } + + /// + /// Enables to override the default isolation level for database transactions, enabling to balance between consistency and performance. Defaults to + /// null, which leaves this up to Entity Framework Core to choose (and then it varies per database provider). + /// + 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 . + /// + /// + /// The next example sets the naming convention to camel casing. + /// + /// + JsonSerializerOptions SerializerOptions { get; } + + /// + /// Gets the settings used for deserializing request bodies. This value is based on and is intended for internal use. + /// + JsonSerializerOptions SerializerReadOptions { get; } + + /// + /// Gets the settings used for serializing response bodies. This value is based on and is intended for internal use. + /// + JsonSerializerOptions SerializerWriteOptions { get; } } diff --git a/src/JsonApiDotNetCore/Configuration/IRequestScopedServiceProvider.cs b/src/JsonApiDotNetCore/Configuration/IRequestScopedServiceProvider.cs deleted file mode 100644 index 327eeca353..0000000000 --- a/src/JsonApiDotNetCore/Configuration/IRequestScopedServiceProvider.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; - -namespace JsonApiDotNetCore.Configuration -{ - /// - /// An interface used to separate the registration of the global from a request-scoped service provider. This is useful - /// in cases when we need to manually resolve services from the request scope (e.g. operation processors). - /// - public interface IRequestScopedServiceProvider : IServiceProvider - { - } -} diff --git a/src/JsonApiDotNetCore/Configuration/IResourceContextProvider.cs b/src/JsonApiDotNetCore/Configuration/IResourceContextProvider.cs deleted file mode 100644 index a452bf30c9..0000000000 --- a/src/JsonApiDotNetCore/Configuration/IResourceContextProvider.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; -using System.Collections.Generic; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCore.Configuration -{ - /// - /// Responsible for getting s from the . - /// - public interface IResourceContextProvider - { - /// - /// Gets all registered resource contexts. - /// - IReadOnlyCollection GetResourceContexts(); - - /// - /// Gets the resource metadata for the specified exposed resource name. - /// - ResourceContext GetResourceContext(string resourceName); - - /// - /// Gets the resource metadata for the specified resource type. - /// - ResourceContext GetResourceContext(Type resourceType); - - /// - /// Gets the resource metadata for the specified resource type. - /// - ResourceContext GetResourceContext() - where TResource : class, IIdentifiable; - } -} diff --git a/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs index acbfc5961e..a2c4f3283c 100644 --- a/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs +++ b/src/JsonApiDotNetCore/Configuration/IResourceGraph.cs @@ -1,84 +1,87 @@ -using System; -using System.Collections.Generic; using System.Linq.Expressions; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +/// +/// Metadata about the shape of JSON:API resources that your API serves and the relationships between them. The resource graph is built at application +/// startup and is exposed as a singleton through Dependency Injection. +/// +[PublicAPI] +public interface IResourceGraph { /// - /// Enables retrieving the exposed resource fields (attributes and relationships) of resources registered in the resource graph. + /// Gets the metadata for all registered resources. /// - [PublicAPI] - public interface IResourceGraph : IResourceContextProvider - { - /// - /// Gets all fields (attributes and relationships) for that are targeted by the selector. If no selector is provided, - /// all exposed fields are returned. - /// - /// - /// The resource for which to retrieve fields. - /// - /// - /// Should be of the form: (TResource e) => new { e.Field1, e.Field2 } - /// - IReadOnlyCollection GetFields(Expression> selector = null) - where TResource : class, IIdentifiable; + IReadOnlySet GetResourceTypes(); - /// - /// Gets all attributes for that are targeted by the selector. If no selector is provided, all exposed fields are - /// returned. - /// - /// - /// The resource for which to retrieve attributes. - /// - /// - /// Should be of the form: (TResource e) => new { e.Attribute1, e.Attribute2 } - /// - IReadOnlyCollection GetAttributes(Expression> selector = null) - where TResource : class, IIdentifiable; + /// + /// Gets the metadata for the resource that is publicly exposed by the specified name. Throws an when not found. + /// + ResourceType GetResourceType(string publicName); - /// - /// Gets all relationships for that are targeted by the selector. If no selector is provided, all exposed fields are - /// returned. - /// - /// - /// The resource for which to retrieve relationships. - /// - /// - /// Should be of the form: (TResource e) => new { e.Relationship1, e.Relationship2 } - /// - IReadOnlyCollection GetRelationships(Expression> selector = null) - where TResource : class, IIdentifiable; + /// + /// Gets the metadata for the resource of the specified CLR type. Throws an when not found. + /// + ResourceType GetResourceType(Type resourceClrType); - /// - /// Gets all exposed fields (attributes and relationships) for the specified type. - /// - /// - /// The resource type. Must implement . - /// - IReadOnlyCollection GetFields(Type type); + /// + /// Gets the metadata for the resource of the specified CLR type. Throws an when not found. + /// + ResourceType GetResourceType() + where TResource : class, IIdentifiable; - /// - /// Gets all exposed attributes for the specified type. - /// - /// - /// The resource type. Must implement . - /// - IReadOnlyCollection GetAttributes(Type type); + /// + /// Attempts to get the metadata for the resource that is publicly exposed by the specified name. Returns null when not found. + /// + ResourceType? FindResourceType(string publicName); - /// - /// Gets all exposed relationships for the specified type. - /// - /// - /// The resource type. Must implement . - /// - IReadOnlyCollection GetRelationships(Type type); + /// + /// Attempts to get metadata for the resource of the specified CLR type. Returns null when not found. + /// + ResourceType? FindResourceType(Type resourceClrType); - /// - /// Traverses the resource graph, looking for the inverse relationship of the specified . - /// - RelationshipAttribute GetInverseRelationship(RelationshipAttribute relationship); - } + /// + /// Gets the fields (attributes and relationships) for that are targeted by the selector. + /// + /// + /// The resource CLR type for which to retrieve fields. + /// + /// + /// Should be of the form: new { resource.Attribute1, resource.Relationship2 } + /// ]]> + /// + IReadOnlyCollection GetFields(Expression> selector) + where TResource : class, IIdentifiable; + + /// + /// Gets the attributes for that are targeted by the selector. + /// + /// + /// The resource CLR type for which to retrieve attributes. + /// + /// + /// Should be of the form: new { resource.Attribute1, resource.Attribute2 } + /// ]]> + /// + IReadOnlyCollection GetAttributes(Expression> selector) + where TResource : class, IIdentifiable; + + /// + /// Gets the relationships for that are targeted by the selector. + /// + /// + /// The resource CLR type for which to retrieve relationships. + /// + /// + /// Should be of the form: new { resource.Relationship1, resource.Relationship2 } + /// ]]> + /// + IReadOnlyCollection GetRelationships(Expression> selector) + where TResource : class, IIdentifiable; } 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 5675a1b023..4527afdcbe 100644 --- a/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs +++ b/src/JsonApiDotNetCore/Configuration/InverseNavigationResolver.cs @@ -1,60 +1,70 @@ -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources.Annotations; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +/// +[PublicAPI] +public sealed class InverseNavigationResolver : IInverseNavigationResolver { - /// - [PublicAPI] - public class InverseNavigationResolver : IInverseNavigationResolver + private readonly IResourceGraph _resourceGraph; + private readonly IDbContextResolver[] _dbContextResolvers; + + public InverseNavigationResolver(IResourceGraph resourceGraph, IEnumerable dbContextResolvers) { - private readonly IResourceContextProvider _resourceContextProvider; - private readonly IEnumerable _dbContextResolvers; + ArgumentNullException.ThrowIfNull(resourceGraph); + ArgumentNullException.ThrowIfNull(dbContextResolvers); - public InverseNavigationResolver(IResourceContextProvider resourceContextProvider, IEnumerable dbContextResolvers) - { - ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider)); - ArgumentGuard.NotNull(dbContextResolvers, nameof(dbContextResolvers)); + _resourceGraph = resourceGraph; + _dbContextResolvers = dbContextResolvers as IDbContextResolver[] ?? dbContextResolvers.ToArray(); + } - _resourceContextProvider = resourceContextProvider; - _dbContextResolvers = dbContextResolvers; + /// + public void Resolve() + { + foreach (IDbContextResolver dbContextResolver in _dbContextResolvers) + { + DbContext dbContext = dbContextResolver.GetContext(); + Resolve(dbContext); } + } - /// - public void Resolve() + private void Resolve(DbContext dbContext) + { + foreach (ResourceType resourceType in _resourceGraph.GetResourceTypes().Where(resourceType => resourceType.Relationships.Count > 0)) { - foreach (IDbContextResolver dbContextResolver in _dbContextResolvers) + IEntityType? entityType = dbContext.Model.FindEntityType(resourceType.ClrType); + + if (entityType != null) { - DbContext dbContext = dbContextResolver.GetContext(); - Resolve(dbContext); + Dictionary navigationMap = GetNavigations(entityType); + ResolveRelationships(resourceType.Relationships, navigationMap); } } + } - private void Resolve(DbContext dbContext) - { - foreach (ResourceContext resourceContext in _resourceContextProvider.GetResourceContexts()) - { - IEntityType entityType = dbContext.Model.FindEntityType(resourceContext.ResourceType); + private static Dictionary GetNavigations(IEntityType entityType) + { + // @formatter:wrap_chained_method_calls chop_always - if (entityType != null) - { - ResolveRelationships(resourceContext.Relationships, entityType); - } - } - } + return entityType.GetNavigations() + .Cast() + .Concat(entityType.GetSkipNavigations()) + .ToDictionary(navigation => navigation.Name); - private void ResolveRelationships(IReadOnlyCollection relationships, IEntityType entityType) + // @formatter:wrap_chained_method_calls restore + } + + private void ResolveRelationships(IReadOnlyCollection relationships, Dictionary navigationMap) + { + foreach (RelationshipAttribute relationship in relationships) { - foreach (RelationshipAttribute relationship in relationships) + if (navigationMap.TryGetValue(relationship.Property.Name, out INavigationBase? navigation)) { - if (!(relationship is HasManyThroughAttribute)) - { - INavigation inverseNavigation = entityType.FindNavigation(relationship.Property.Name)?.FindInverse(); - relationship.InverseNavigationProperty = inverseNavigation?.PropertyInfo; - } + relationship.InverseNavigationProperty = navigation.Inverse?.PropertyInfo; } } } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index eb2be47056..65646b7697 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -1,320 +1,308 @@ -using System; -using System.Collections.Generic; -using System.Linq; using JsonApiDotNetCore.AtomicOperations; using JsonApiDotNetCore.AtomicOperations.Processors; -using JsonApiDotNetCore.Hooks; -using JsonApiDotNetCore.Hooks.Internal; -using JsonApiDotNetCore.Hooks.Internal.Discovery; -using JsonApiDotNetCore.Hooks.Internal.Execution; -using JsonApiDotNetCore.Hooks.Internal.Traversal; 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; -using JsonApiDotNetCore.Serialization.Building; +using JsonApiDotNetCore.Serialization.Request; +using JsonApiDotNetCore.Serialization.Request.Adapters; +using JsonApiDotNetCore.Serialization.Response; using JsonApiDotNetCore.Services; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; -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 - /// configuration. - /// - internal sealed class JsonApiApplicationBuilder : IJsonApiApplicationBuilder, IDisposable - { - private readonly JsonApiOptions _options = new JsonApiOptions(); - private readonly IServiceCollection _services; - private readonly IMvcCoreBuilder _mvcBuilder; - private readonly ResourceGraphBuilder _resourceGraphBuilder; - private readonly ServiceDiscoveryFacade _serviceDiscoveryFacade; - private readonly ServiceProvider _intermediateProvider; +namespace JsonApiDotNetCore.Configuration; - public Action ConfigureMvcOptions { get; set; } +/// +/// 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 +{ + private readonly IServiceCollection _services; + private readonly IMvcCoreBuilder _mvcBuilder; + private readonly JsonApiOptions _options = new(); + private readonly ResourceDescriptorAssemblyCache _assemblyCache = new(); - public JsonApiApplicationBuilder(IServiceCollection services, IMvcCoreBuilder mvcBuilder) - { - ArgumentGuard.NotNull(services, nameof(services)); - ArgumentGuard.NotNull(mvcBuilder, nameof(mvcBuilder)); + public Action? ConfigureMvcOptions { get; set; } - _services = services; - _mvcBuilder = mvcBuilder; - _intermediateProvider = services.BuildServiceProvider(); + public JsonApiApplicationBuilder(IServiceCollection services, IMvcCoreBuilder mvcBuilder) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(mvcBuilder); - var loggerFactory = _intermediateProvider.GetRequiredService(); + _services = services; + _mvcBuilder = mvcBuilder; + } - _resourceGraphBuilder = new ResourceGraphBuilder(_options, loggerFactory); - _serviceDiscoveryFacade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, loggerFactory); - } + /// + /// Executes the action provided by the user to configure . + /// + public void ConfigureJsonApiOptions(Action? configureOptions) + { + configureOptions?.Invoke(_options); + } - /// - /// Executes the action provided by the user to configure . - /// - public void ConfigureJsonApiOptions(Action configureOptions) + /// + /// Executes the action provided by the user to configure auto-discovery. + /// + public void ConfigureAutoDiscovery(Action? configureAutoDiscovery) + { + if (configureAutoDiscovery != null) { - configureOptions?.Invoke(_options); + var facade = new ServiceDiscoveryFacade(_assemblyCache); + configureAutoDiscovery.Invoke(facade); } + } - /// - /// Executes the action provided by the user to configure . - /// - public void ConfigureAutoDiscovery(Action configureAutoDiscovery) - { - configureAutoDiscovery?.Invoke(_serviceDiscoveryFacade); - } + /// + /// 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) + { + ArgumentNullException.ThrowIfNull(dbContextTypes); - /// - /// Configures and builds the resource graph with resources from the provided sources and adds it to the DI container. - /// - public void AddResourceGraph(ICollection dbContextTypes, Action configureResourceGraph) + _services.TryAddSingleton(serviceProvider => { - _serviceDiscoveryFacade.DiscoverResources(); + var loggerFactory = serviceProvider.GetRequiredService(); + var events = serviceProvider.GetRequiredService(); - foreach (Type dbContextType in dbContextTypes) + var resourceGraphBuilder = new ResourceGraphBuilder(_options, loggerFactory); + + var scanner = new ResourcesAssemblyScanner(_assemblyCache, resourceGraphBuilder); + scanner.DiscoverResources(); + + if (dbContextTypes.Count > 0) { - var dbContext = (DbContext)_intermediateProvider.GetRequiredService(dbContextType); - AddResourcesFromDbContext(dbContext, _resourceGraphBuilder); + using IServiceScope scope = serviceProvider.CreateScope(); + + foreach (Type dbContextType in dbContextTypes) + { + var dbContext = (DbContext)scope.ServiceProvider.GetRequiredService(dbContextType); + resourceGraphBuilder.Add(dbContext); + } } - configureResourceGraph?.Invoke(_resourceGraphBuilder); + configureResourceGraph?.Invoke(resourceGraphBuilder); - IResourceGraph resourceGraph = _resourceGraphBuilder.Build(); - _services.AddSingleton(resourceGraph); - } + IResourceGraph resourceGraph = resourceGraphBuilder.Build(); + events.ResourceGraphBuilt(resourceGraph); - /// - /// Configures built-in ASP.NET Core MVC components. Most of this configuration can be adjusted for the developers' need. - /// - public void ConfigureMvc() - { - _mvcBuilder.AddMvcOptions(options => - { - options.EnableEndpointRouting = true; - options.Filters.AddService(); - options.Filters.AddService(); - options.Filters.AddService(); - ConfigureMvcOptions?.Invoke(options); - }); - - if (_options.ValidateModelState) - { - _mvcBuilder.AddDataAnnotations(); - _services.AddSingleton(); - } - } + return resourceGraph; + }); + } - /// - /// Discovers DI registrable services in the assemblies marked for discovery. - /// - public void DiscoverInjectables() + /// + /// Configures built-in ASP.NET MVC components. Most of this configuration can be adjusted for the developers' need. + /// + public void ConfigureMvc() + { + _mvcBuilder.AddMvcOptions(options => + { + options.EnableEndpointRouting = true; + options.Filters.AddService(); + options.Filters.AddService(); + options.Filters.AddService(); + ConfigureMvcOptions?.Invoke(options); + }); + + if (_options.ValidateModelState) { - _serviceDiscoveryFacade.DiscoverInjectables(); + _mvcBuilder.AddDataAnnotations(); + _services.Replace(new ServiceDescriptor(typeof(IModelMetadataProvider), typeof(JsonApiModelMetadataProvider), ServiceLifetime.Singleton)); } + } - /// - /// Registers the remaining internals. - /// - public void ConfigureServiceContainer(ICollection dbContextTypes) - { - if (dbContextTypes.Any()) - { - _services.AddScoped(typeof(DbContextResolver<>)); + /// + /// Registers injectables in the IoC container found in assemblies marked for auto-discovery. + /// + public void DiscoverInjectables() + { + var scanner = new InjectablesAssemblyScanner(_assemblyCache, _services); + scanner.DiscoverInjectables(); + } - foreach (Type dbContextType in dbContextTypes) - { - Type contextResolverType = typeof(DbContextResolver<>).MakeGenericType(dbContextType); - _services.AddScoped(typeof(IDbContextResolver), contextResolverType); - } + /// + /// Registers the remaining internals in the IoC container. + /// + public void ConfigureServiceContainer(ICollection dbContextTypes) + { + ArgumentNullException.ThrowIfNull(dbContextTypes); - _services.AddScoped(); - } - else + if (dbContextTypes.Count > 0) + { + _services.TryAddScoped(typeof(DbContextResolver<>)); + + foreach (Type dbContextType in dbContextTypes) { - _services.AddScoped(); + Type dbContextResolverClosedType = typeof(DbContextResolver<>).MakeGenericType(dbContextType); + _services.TryAddScoped(typeof(IDbContextResolver), dbContextResolverClosedType); } - AddResourceLayer(); - AddRepositoryLayer(); - AddServiceLayer(); - AddMiddlewareLayer(); - AddSerializationLayer(); - AddQueryStringLayer(); - AddOperationsLayer(); - - AddResourceHooks(); - - _services.AddScoped(); - _services.AddScoped(typeof(IResourceChangeTracker<>), typeof(ResourceChangeTracker<>)); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); + _services.TryAddScoped(); } - - private void AddMiddlewareLayer() + else { - _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.AddSingleton(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); + _services.TryAddScoped(); } - private void AddResourceLayer() - { - RegisterImplementationForOpenInterfaces(ServiceDiscoveryFacade.ResourceDefinitionInterfaces, typeof(JsonApiResourceDefinition<>), - typeof(JsonApiResourceDefinition<,>)); - - _services.AddScoped(); - _services.AddScoped(); - _services.AddSingleton(sp => sp.GetRequiredService()); - } + AddResourceLayer(); + AddRepositoryLayer(); + AddServiceLayer(); + AddMiddlewareLayer(); + AddSerializationLayer(); + AddQueryStringLayer(); + AddOperationsLayer(); + + _services.TryAddScoped(typeof(IResourceChangeTracker<>), typeof(ResourceChangeTracker<>)); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddScoped(); + _services.TryAddSingleton(); + _services.TryAddSingleton(); + } - private void AddRepositoryLayer() - { - RegisterImplementationForOpenInterfaces(ServiceDiscoveryFacade.RepositoryInterfaces, typeof(EntityFrameworkCoreRepository<>), - typeof(EntityFrameworkCoreRepository<,>)); + private void AddMiddlewareLayer() + { + _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(); + } - _services.AddScoped(); - } + private void AddResourceLayer() + { + RegisterImplementationForInterfaces(InjectablesAssemblyScanner.ResourceDefinitionUnboundInterfaces, typeof(JsonApiResourceDefinition<,>)); - private void AddServiceLayer() - { - RegisterImplementationForOpenInterfaces(ServiceDiscoveryFacade.ServiceInterfaces, typeof(JsonApiResourceService<>), - typeof(JsonApiResourceService<,>)); - } + _services.TryAddScoped(); + _services.TryAddScoped(); + } - private void RegisterImplementationForOpenInterfaces(HashSet openGenericInterfaces, Type intImplementation, Type implementation) - { - foreach (Type openGenericInterface in openGenericInterfaces) - { - Type implementationType = openGenericInterface.GetGenericArguments().Length == 1 ? intImplementation : implementation; + private void AddRepositoryLayer() + { + RegisterImplementationForInterfaces(InjectablesAssemblyScanner.RepositoryUnboundInterfaces, typeof(EntityFrameworkCoreRepository<,>)); - _services.TryAddScoped(openGenericInterface, implementationType); - } - } + _services.TryAddScoped(); - private void AddQueryStringLayer() - { - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - - RegisterDependentService(); - RegisterDependentService(); - RegisterDependentService(); - RegisterDependentService(); - RegisterDependentService(); - RegisterDependentService(); - RegisterDependentService(); - RegisterDependentService(); - - RegisterDependentService(); - RegisterDependentService(); - RegisterDependentService(); - RegisterDependentService(); - RegisterDependentService(); - RegisterDependentService(); - - _services.AddScoped(); - _services.AddSingleton(); - } + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.TryAddTransient(); + _services.TryAddTransient(); + } - private void RegisterDependentService() - where TCollectionElement : class - where TElementToAdd : TCollectionElement - { - _services.AddScoped(serviceProvider => serviceProvider.GetRequiredService()); - } + private void AddServiceLayer() + { + RegisterImplementationForInterfaces(InjectablesAssemblyScanner.ServiceUnboundInterfaces, typeof(JsonApiResourceService<,>)); + } - private void AddResourceHooks() + private void RegisterImplementationForInterfaces(HashSet unboundInterfaces, Type unboundImplementationType) + { + foreach (Type unboundInterface in unboundInterfaces) { - if (_options.EnableResourceHooks) - { - _services.AddSingleton(typeof(IHooksDiscovery<>), typeof(HooksDiscovery<>)); - _services.AddScoped(typeof(IResourceHookContainer<>), typeof(ResourceHooksDefinition<>)); - _services.AddTransient(); - _services.AddTransient(); - _services.AddScoped(); - _services.AddScoped(); - } - else - { - _services.AddSingleton(); - } + _services.TryAddScoped(unboundInterface, unboundImplementationType); } + } - private void AddSerializationLayer() - { - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(); - _services.AddScoped(typeof(ResponseSerializer<>)); - _services.AddScoped(typeof(AtomicOperationsResponseSerializer)); - _services.AddScoped(sp => sp.GetRequiredService().GetSerializer()); - _services.AddScoped(); - _services.AddSingleton(); - _services.AddSingleton(); - } + private void AddQueryStringLayer() + { + _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(); + RegisterDependentService(); + RegisterDependentService(); + RegisterDependentService(); + RegisterDependentService(); + + RegisterDependentService(); + RegisterDependentService(); + RegisterDependentService(); + RegisterDependentService(); + RegisterDependentService(); + RegisterDependentService(); + + _services.TryAddScoped(); + _services.TryAddSingleton(); + } - 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(); - } + private void RegisterDependentService() + where TCollectionElement : class + where TElementToAdd : TCollectionElement + { + _services.AddScoped(provider => provider.GetRequiredService()); + } - private void AddResourcesFromDbContext(DbContext dbContext, ResourceGraphBuilder builder) - { - foreach (IEntityType entityType in dbContext.Model.GetEntityTypes()) - { - builder.Add(entityType.ClrType); - } - } + private void AddSerializationLayer() + { + _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(); + } - public void Dispose() - { - _intermediateProvider.Dispose(); - } + private void AddOperationsLayer() + { + _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 19f9edc531..5e1941a165 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiModelMetadataProvider.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiModelMetadataProvider.cs @@ -1,43 +1,41 @@ +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; using Microsoft.Extensions.Options; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +/// +/// Custom implementation of to support JSON:API partial patching. +/// +internal sealed class JsonApiModelMetadataProvider : DefaultModelMetadataProvider { - /// - /// Custom implementation of to support JSON:API partial patching. - /// - internal sealed class JsonApiModelMetadataProvider : DefaultModelMetadataProvider - { - private readonly JsonApiValidationFilter _jsonApiValidationFilter; + private readonly JsonApiValidationFilter _jsonApiValidationFilter; - /// - public JsonApiModelMetadataProvider(ICompositeMetadataDetailsProvider detailsProvider, IRequestScopedServiceProvider serviceProvider) - : base(detailsProvider) - { - _jsonApiValidationFilter = new JsonApiValidationFilter(serviceProvider); - } + /// + public JsonApiModelMetadataProvider(ICompositeMetadataDetailsProvider detailsProvider, IHttpContextAccessor httpContextAccessor) + : base(detailsProvider) + { + _jsonApiValidationFilter = new JsonApiValidationFilter(httpContextAccessor); + } - /// - public JsonApiModelMetadataProvider(ICompositeMetadataDetailsProvider detailsProvider, IOptions optionsAccessor, - IRequestScopedServiceProvider serviceProvider) - : base(detailsProvider, optionsAccessor) - { - _jsonApiValidationFilter = new JsonApiValidationFilter(serviceProvider); - } + /// + public JsonApiModelMetadataProvider(ICompositeMetadataDetailsProvider detailsProvider, IOptions optionsAccessor, + IHttpContextAccessor httpContextAccessor) + : base(detailsProvider, optionsAccessor) + { + _jsonApiValidationFilter = new JsonApiValidationFilter(httpContextAccessor); + } - /// - protected override ModelMetadata CreateModelMetadata(DefaultMetadataDetails entry) - { - var metadata = (DefaultModelMetadata)base.CreateModelMetadata(entry); + /// + protected override ModelMetadata CreateModelMetadata(DefaultMetadataDetails 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; - } + return metadata; } } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index 8214b576ed..182c51431b 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -1,97 +1,161 @@ using System.Data; +using System.Text.Encodings.Web; +using System.Text.Json; using JetBrains.Annotations; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources.Annotations; -using Newtonsoft.Json; -using Newtonsoft.Json.Serialization; +using JsonApiDotNetCore.Serialization.JsonConverters; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +/// +[PublicAPI] +public sealed class JsonApiOptions : IJsonApiOptions { + private static readonly IReadOnlySet EmptyExtensionSet = new HashSet().AsReadOnly(); + private readonly Lazy _lazySerializerWriteOptions; + private readonly Lazy _lazySerializerReadOptions; + /// - [PublicAPI] - public sealed class JsonApiOptions : IJsonApiOptions - { - internal static readonly NamingStrategy DefaultNamingStrategy = new CamelCaseNamingStrategy(); + JsonSerializerOptions IJsonApiOptions.SerializerReadOptions => _lazySerializerReadOptions.Value; + + /// + JsonSerializerOptions IJsonApiOptions.SerializerWriteOptions => _lazySerializerWriteOptions.Value; + + /// + public string? Namespace { get; set; } + + /// + 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; } - // Workaround for https://github.com/dotnet/efcore/issues/21026 - internal bool DisableTopPagination { get; set; } - internal bool DisableChildrenPagination { get; set; } + /// + public bool IncludeExceptionStackTraceInErrors { get; set; } - /// - public string Namespace { get; set; } + /// + public bool IncludeRequestBodyInErrors { get; set; } - /// - public AttrCapabilities DefaultAttrCapabilities { get; set; } = AttrCapabilities.All; + /// + public bool UseRelativeLinks { get; set; } - /// - public bool IncludeJsonApiVersion { get; set; } + /// + public LinkTypes TopLevelLinks { get; set; } = LinkTypes.All; - /// - public bool IncludeExceptionStackTraceInErrors { get; set; } + /// + public LinkTypes ResourceLinks { get; set; } = LinkTypes.All; - /// - public bool UseRelativeLinks { get; set; } + /// + public LinkTypes RelationshipLinks { get; set; } = LinkTypes.All; - /// - public LinkTypes TopLevelLinks { get; set; } = LinkTypes.All; + /// + public bool IncludeTotalResourceCount { get; set; } - /// - public LinkTypes ResourceLinks { get; set; } = LinkTypes.All; + /// + public PageSize? DefaultPageSize { get; set; } = new(10); - /// - public LinkTypes RelationshipLinks { get; set; } = LinkTypes.All; + /// + public PageSize? MaximumPageSize { get; set; } - /// - public bool IncludeTotalResourceCount { get; set; } + /// + public PageNumber? MaximumPageNumber { get; set; } - /// - public PageSize DefaultPageSize { get; set; } = new PageSize(10); + /// + public bool ValidateModelState { get; set; } = true; - /// - public PageSize MaximumPageSize { get; set; } + /// + public ClientIdGenerationMode ClientIdGeneration { get; set; } - /// - public PageNumber MaximumPageNumber { 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 ValidateModelState { get; set; } + /// + public bool AllowUnknownQueryStringParameters { get; set; } - /// - public bool AllowClientGeneratedIds { get; set; } + /// + public bool AllowUnknownFieldsInRequestBody { get; set; } - /// - public bool EnableResourceHooks { get; set; } + /// + public bool EnableLegacyFilterNotation { get; set; } - /// - public bool LoadDatabaseValues { get; set; } + /// + public int? MaximumIncludeDepth { get; set; } - /// - public bool AllowUnknownQueryStringParameters { get; set; } + /// + public int? MaximumOperationsPerRequest { get; set; } = 10; - /// - public bool EnableLegacyFilterNotation { get; set; } + /// + public IsolationLevel? TransactionIsolationLevel { get; set; } - /// - public bool AllowQueryStringOverrideForSerializerNullValueHandling { get; set; } + /// + public IReadOnlySet Extensions { get; set; } = EmptyExtensionSet; - /// - public bool AllowQueryStringOverrideForSerializerDefaultValueHandling { get; set; } + /// + public JsonSerializerOptions SerializerOptions { get; } = new() + { + // These are the options common to serialization and deserialization. + // At runtime, we actually use SerializerReadOptions and SerializerWriteOptions, which are customized copies of these settings, + // to overcome the limitation in System.Text.Json that the JsonPath is incorrect when using custom converters. + // Therefore we try to avoid using custom converters has much as possible. + // https://github.com/Tarmil/FSharp.SystemTextJson/issues/37 + // https://github.com/dotnet/runtime/issues/50205#issuecomment-808401245 + + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + Converters = + { + new SingleOrManyDataConverterFactory() + } + }; - /// - public int? MaximumIncludeDepth { get; set; } + public JsonApiOptions() + { + _lazySerializerReadOptions = + new Lazy(() => new JsonSerializerOptions(SerializerOptions), LazyThreadSafetyMode.ExecutionAndPublication); - /// - public int? MaximumOperationsPerRequest { get; set; } = 10; + _lazySerializerWriteOptions = new Lazy(() => new JsonSerializerOptions(SerializerOptions) + { + Converters = + { + new WriteOnlyDocumentConverter(), + new WriteOnlyRelationshipObjectConverter() + } + }, LazyThreadSafetyMode.ExecutionAndPublication); + } - /// - public IsolationLevel? TransactionIsolationLevel { get; set; } + /// + /// 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); - /// - public JsonSerializerSettings SerializerSettings { get; } = new JsonSerializerSettings + if (!Extensions.IsSupersetOf(extensionsToAdd)) { - ContractResolver = new DefaultContractResolver + var extensions = new HashSet(Extensions); + + foreach (JsonApiMediaTypeExtension extension in extensionsToAdd) { - NamingStrategy = DefaultNamingStrategy + extensions.Add(extension); } - }; + + Extensions = extensions.AsReadOnly(); + } } } diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs b/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs index f4f9d5e1a7..30b850e8b8 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiValidationFilter.cs @@ -1,68 +1,77 @@ -using System; -using System.Linq; 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; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +/// +/// Validation filter that blocks ASP.NET ModelState validation on data according to the JSON:API spec. +/// +internal sealed class JsonApiValidationFilter : IPropertyValidationFilter { - /// - /// Validation filter that blocks ASP.NET Core ModelState validation on data according to the JSON:API spec. - /// - internal sealed class JsonApiValidationFilter : IPropertyValidationFilter - { - private readonly IRequestScopedServiceProvider _serviceProvider; + private readonly IHttpContextAccessor _httpContextAccessor; - public JsonApiValidationFilter(IRequestScopedServiceProvider serviceProvider) - { - ArgumentGuard.NotNull(serviceProvider, nameof(serviceProvider)); + public JsonApiValidationFilter(IHttpContextAccessor httpContextAccessor) + { + ArgumentNullException.ThrowIfNull(httpContextAccessor); - _serviceProvider = serviceProvider; - } + _httpContextAccessor = httpContextAccessor; + } - /// - public bool ShouldValidateEntry(ValidationEntry entry, ValidationEntry parentEntry) + /// + public bool ShouldValidateEntry(ValidationEntry entry, ValidationEntry parentEntry) + { + if (entry.Metadata.MetadataKind == ModelMetadataKind.Type || IsId(entry.Key)) { - var request = _serviceProvider.GetRequiredService(); - - if (IsId(entry.Key)) - { - return true; - } - - bool isTopResourceInPrimaryRequest = string.IsNullOrEmpty(parentEntry.Key) && IsAtPrimaryEndpoint(request); - - if (!isTopResourceInPrimaryRequest) - { - return false; - } - - var httpContextAccessor = _serviceProvider.GetRequiredService(); - - if (httpContextAccessor.HttpContext.Request.Method == HttpMethods.Patch || request.OperationKind == OperationKind.UpdateResource) - { - var targetedFields = _serviceProvider.GetRequiredService(); - return IsFieldTargeted(entry, targetedFields); - } - return true; } - private static bool IsId(string key) + IServiceProvider serviceProvider = GetScopedServiceProvider(); + var request = serviceProvider.GetRequiredService(); + bool isTopResourceInPrimaryRequest = string.IsNullOrEmpty(parentEntry.Key) && IsAtPrimaryEndpoint(request); + + if (!isTopResourceInPrimaryRequest) { - return key == nameof(Identifiable.Id) || key.EndsWith("." + nameof(Identifiable.Id), StringComparison.Ordinal); + return false; } - private static bool IsAtPrimaryEndpoint(IJsonApiRequest request) + if (request.WriteOperation == WriteOperationKind.UpdateResource) { - return request.Kind == EndpointKind.Primary || request.Kind == EndpointKind.AtomicOperations; + var targetedFields = serviceProvider.GetRequiredService(); + return IsFieldTargeted(entry, targetedFields); } - private static bool IsFieldTargeted(ValidationEntry entry, ITargetedFields targetedFields) + return true; + } + + private IServiceProvider GetScopedServiceProvider() + { + HttpContext? httpContext = _httpContextAccessor.HttpContext; + + if (httpContext == null) { - return targetedFields.Attributes.Any(attribute => attribute.Property.Name == entry.Key); + throw new InvalidOperationException("Cannot resolve scoped services outside the context of an HTTP request."); } + + return httpContext.RequestServices; + } + + private static bool IsId(string key) + { + return key == nameof(Identifiable.Id) || key.EndsWith($".{nameof(Identifiable.Id)}", StringComparison.Ordinal); + } + + private static bool IsAtPrimaryEndpoint(IJsonApiRequest request) + { + return request.Kind is EndpointKind.Primary or EndpointKind.AtomicOperations; + } + + private static bool IsFieldTargeted(ValidationEntry entry, ITargetedFields targetedFields) + { + return targetedFields.Attributes.Any(attribute => attribute.Property.Name == entry.Key) || + targetedFields.Relationships.Any(relationship => relationship.Property.Name == entry.Key); } } diff --git a/src/JsonApiDotNetCore/Configuration/PageNumber.cs b/src/JsonApiDotNetCore/Configuration/PageNumber.cs index 81561aefcb..f4af725a3d 100644 --- a/src/JsonApiDotNetCore/Configuration/PageNumber.cs +++ b/src/JsonApiDotNetCore/Configuration/PageNumber.cs @@ -1,53 +1,48 @@ -using System; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +[PublicAPI] +public sealed class PageNumber : IEquatable { - [PublicAPI] - public sealed class PageNumber : IEquatable - { - public static readonly PageNumber ValueOne = new PageNumber(1); + public static readonly PageNumber ValueOne = new(1); - public int OneBasedValue { get; } + public int OneBasedValue { get; } - public PageNumber(int oneBasedValue) - { - if (oneBasedValue < 1) - { - throw new ArgumentOutOfRangeException(nameof(oneBasedValue)); - } + public PageNumber(int oneBasedValue) + { + ArgumentOutOfRangeException.ThrowIfLessThan(oneBasedValue, 1); - OneBasedValue = oneBasedValue; - } + OneBasedValue = oneBasedValue; + } - public bool Equals(PageNumber other) + public bool Equals(PageNumber? other) + { + if (other is null) { - if (ReferenceEquals(null, other)) - { - return false; - } - - if (ReferenceEquals(this, other)) - { - return true; - } - - return OneBasedValue == other.OneBasedValue; + return false; } - public override bool Equals(object other) + if (ReferenceEquals(this, other)) { - return Equals(other as PageNumber); + return true; } - public override int GetHashCode() - { - return OneBasedValue.GetHashCode(); - } + return OneBasedValue == other.OneBasedValue; + } - public override string ToString() - { - return OneBasedValue.ToString(); - } + public override bool Equals(object? other) + { + return Equals(other as PageNumber); + } + + public override int GetHashCode() + { + return OneBasedValue.GetHashCode(); + } + + public override string ToString() + { + return OneBasedValue.ToString(); } } diff --git a/src/JsonApiDotNetCore/Configuration/PageSize.cs b/src/JsonApiDotNetCore/Configuration/PageSize.cs index 4533461502..4581992597 100644 --- a/src/JsonApiDotNetCore/Configuration/PageSize.cs +++ b/src/JsonApiDotNetCore/Configuration/PageSize.cs @@ -1,51 +1,46 @@ -using System; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +[PublicAPI] +public sealed class PageSize : IEquatable { - [PublicAPI] - public sealed class PageSize : IEquatable - { - public int Value { get; } + public int Value { get; } - public PageSize(int value) - { - if (value < 1) - { - throw new ArgumentOutOfRangeException(nameof(value)); - } + public PageSize(int value) + { + ArgumentOutOfRangeException.ThrowIfLessThan(value, 1); - Value = value; - } + Value = value; + } - public bool Equals(PageSize other) + public bool Equals(PageSize? other) + { + if (other is null) { - if (ReferenceEquals(null, other)) - { - return false; - } - - if (ReferenceEquals(this, other)) - { - return true; - } - - return Value == other.Value; + return false; } - public override bool Equals(object other) + if (ReferenceEquals(this, other)) { - return Equals(other as PageSize); + return true; } - public override int GetHashCode() - { - return Value.GetHashCode(); - } + return Value == other.Value; + } - public override string ToString() - { - return Value.ToString(); - } + public override bool Equals(object? other) + { + return Equals(other as PageSize); + } + + public override int GetHashCode() + { + return Value.GetHashCode(); + } + + public override string ToString() + { + return Value.ToString(); } } diff --git a/src/JsonApiDotNetCore/Configuration/RequestScopedServiceProvider.cs b/src/JsonApiDotNetCore/Configuration/RequestScopedServiceProvider.cs deleted file mode 100644 index 649a219c0b..0000000000 --- a/src/JsonApiDotNetCore/Configuration/RequestScopedServiceProvider.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using Microsoft.AspNetCore.Http; - -namespace JsonApiDotNetCore.Configuration -{ - /// - public sealed class RequestScopedServiceProvider : IRequestScopedServiceProvider - { - private readonly IHttpContextAccessor _httpContextAccessor; - - public RequestScopedServiceProvider(IHttpContextAccessor httpContextAccessor) - { - ArgumentGuard.NotNull(httpContextAccessor, nameof(httpContextAccessor)); - - _httpContextAccessor = httpContextAccessor; - } - - /// - public object GetService(Type serviceType) - { - ArgumentGuard.NotNull(serviceType, nameof(serviceType)); - - if (_httpContextAccessor.HttpContext == null) - { - throw new InvalidOperationException($"Cannot resolve scoped service '{serviceType.FullName}' outside the context of an HTTP request. " + - "If you are hitting this error in automated tests, you should instead inject your own " + - "IRequestScopedServiceProvider implementation. See the GitHub repository for how we do this internally. " + - "https://github.com/json-api-dotnet/JsonApiDotNetCore/search?q=TestScopedServiceProvider&unscoped_q=TestScopedServiceProvider"); - } - - return _httpContextAccessor.HttpContext.RequestServices.GetService(serviceType); - } - } -} diff --git a/src/JsonApiDotNetCore/Configuration/ResourceContext.cs b/src/JsonApiDotNetCore/Configuration/ResourceContext.cs deleted file mode 100644 index 4ba2975a10..0000000000 --- a/src/JsonApiDotNetCore/Configuration/ResourceContext.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Configuration -{ - /// - /// Provides metadata for a resource, such as its attributes and relationships. - /// - [PublicAPI] - public class ResourceContext - { - private IReadOnlyCollection _fields; - - /// - /// The publicly exposed resource name. - /// - public string PublicName { get; set; } - - /// - /// The CLR type of the resource. - /// - public Type ResourceType { get; set; } - - /// - /// The identity type of the resource. - /// - public Type IdentityType { get; set; } - - /// - /// Exposed resource attributes. See https://jsonapi.org/format/#document-resource-object-attributes. - /// - public IReadOnlyCollection Attributes { get; set; } - - /// - /// Exposed resource relationships. See https://jsonapi.org/format/#document-resource-object-relationships. - /// - public IReadOnlyCollection Relationships { get; set; } - - /// - /// Related entities that are not exposed as resource relationships. - /// - public IReadOnlyCollection EagerLoads { get; set; } - - /// - /// Exposed resource attributes and relationships. See https://jsonapi.org/format/#document-resource-object-fields. - /// - public IReadOnlyCollection Fields => _fields ??= Attributes.Cast().Concat(Relationships).ToArray(); - - /// - /// Configures which links to show in the object for this resource type. Defaults to - /// , which falls back to . - /// - /// - /// In the process of building the resource graph, this value is set based on usage. - /// - public LinkTypes TopLevelLinks { get; internal set; } = LinkTypes.NotConfigured; - - /// - /// Configures which links to show in the object for this resource type. Defaults to - /// , which falls back to . - /// - /// - /// In the process of building the resource graph, this value is set based on usage. - /// - public LinkTypes ResourceLinks { get; internal set; } = LinkTypes.NotConfigured; - - /// - /// Configures which links to show in the object for all relationships of this resource type. - /// Defaults to , which falls back to . This can be overruled per - /// relationship by setting . - /// - /// - /// In the process of building the resource graph, this value is set based on usage. - /// - public LinkTypes RelationshipLinks { get; internal set; } = LinkTypes.NotConfigured; - - public override string ToString() - { - return PublicName; - } - } -} diff --git a/src/JsonApiDotNetCore/Configuration/ResourceDescriptor.cs b/src/JsonApiDotNetCore/Configuration/ResourceDescriptor.cs index 70a14513ae..9c0ce2cf30 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceDescriptor.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceDescriptor.cs @@ -1,16 +1,16 @@ -using System; +namespace JsonApiDotNetCore.Configuration; -namespace JsonApiDotNetCore.Configuration +internal sealed class ResourceDescriptor { - internal sealed class ResourceDescriptor + public Type ResourceClrType { get; } + public Type IdClrType { get; } + + public ResourceDescriptor(Type resourceClrType, Type idClrType) { - public Type ResourceType { get; } - public Type IdType { get; } + ArgumentNullException.ThrowIfNull(resourceClrType); + ArgumentNullException.ThrowIfNull(idClrType); - public ResourceDescriptor(Type resourceType, Type idType) - { - ResourceType = resourceType; - IdType = idType; - } + ResourceClrType = resourceClrType; + IdClrType = idClrType; } } diff --git a/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs b/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs index ed919c044d..78d83425ce 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceDescriptorAssemblyCache.cs @@ -1,60 +1,51 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Reflection; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +/// +/// Used to scan assemblies for types and cache them, to facilitate resource auto-discovery. +/// +internal sealed class ResourceDescriptorAssemblyCache { - /// - /// Used to scan assemblies for types and cache them, to facilitate resource auto-discovery. - /// - internal sealed class ResourceDescriptorAssemblyCache + private readonly TypeLocator _typeLocator = new(); + private readonly Dictionary _resourceDescriptorsPerAssembly = []; + + public void RegisterAssembly(Assembly assembly) { - private readonly TypeLocator _typeLocator = new TypeLocator(); + _resourceDescriptorsPerAssembly.TryAdd(assembly, null); + } - private readonly Dictionary> _resourceDescriptorsPerAssembly = - new Dictionary>(); + public IReadOnlyCollection GetResourceDescriptors() + { + EnsureAssembliesScanned(); - public void RegisterAssembly(Assembly assembly) - { - if (!_resourceDescriptorsPerAssembly.ContainsKey(assembly)) - { - _resourceDescriptorsPerAssembly[assembly] = null; - } - } + return _resourceDescriptorsPerAssembly.SelectMany(pair => pair.Value!).ToArray().AsReadOnly(); + } - public IReadOnlyCollection GetResourceDescriptors() - { - EnsureAssembliesScanned(); + public IReadOnlyCollection GetAssemblies() + { + EnsureAssembliesScanned(); - return _resourceDescriptorsPerAssembly.SelectMany(pair => pair.Value).ToArray(); - } + return _resourceDescriptorsPerAssembly.Keys; + } - public IReadOnlyCollection GetAssemblies() + private void EnsureAssembliesScanned() + { + foreach (Assembly assemblyToScan in _resourceDescriptorsPerAssembly.Where(pair => pair.Value == null).Select(pair => pair.Key).ToArray()) { - EnsureAssembliesScanned(); - - return _resourceDescriptorsPerAssembly.Keys; + _resourceDescriptorsPerAssembly[assemblyToScan] = ScanForResourceDescriptors(assemblyToScan).ToArray(); } + } - private void EnsureAssembliesScanned() + private IEnumerable ScanForResourceDescriptors(Assembly assembly) + { + foreach (Type type in assembly.GetTypes()) { - foreach (Assembly assemblyToScan in _resourceDescriptorsPerAssembly.Where(pair => pair.Value == null).Select(pair => pair.Key).ToArray()) - { - _resourceDescriptorsPerAssembly[assemblyToScan] = ScanForResourceDescriptors(assemblyToScan).ToArray(); - } - } + ResourceDescriptor? resourceDescriptor = _typeLocator.ResolveResourceDescriptor(type); - private IEnumerable ScanForResourceDescriptors(Assembly assembly) - { - foreach (Type type in assembly.GetTypes()) + if (resourceDescriptor != null) { - ResourceDescriptor resourceDescriptor = _typeLocator.TryGetResourceDescriptor(type); - - if (resourceDescriptor != null) - { - yield return resourceDescriptor; - } + yield return resourceDescriptor; } } } diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs index 1fc36ca4a2..a4ffd082d3 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraph.cs @@ -1,222 +1,204 @@ -using System; -using System.Collections.Generic; -using System.Linq; +using System.Collections.ObjectModel; using System.Linq.Expressions; using System.Reflection; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +/// +[PublicAPI] +public sealed class ResourceGraph : IResourceGraph { - /// - [PublicAPI] - public class ResourceGraph : IResourceGraph + private static readonly Type? ProxyTargetAccessorType = Type.GetType("Castle.DynamicProxy.IProxyTargetAccessor, Castle.Core"); + + private readonly IReadOnlySet _resourceTypeSet; + private readonly Dictionary _resourceTypesByClrType = []; + private readonly Dictionary _resourceTypesByPublicName = []; + + public ResourceGraph(IReadOnlySet resourceTypeSet) { - private static readonly Type ProxyTargetAccessorType = Type.GetType("Castle.DynamicProxy.IProxyTargetAccessor, Castle.Core"); - private readonly IReadOnlyCollection _resources; + ArgumentNullException.ThrowIfNull(resourceTypeSet); - public ResourceGraph(IReadOnlyCollection resources) - { - ArgumentGuard.NotNull(resources, nameof(resources)); + _resourceTypeSet = resourceTypeSet; - _resources = resources; + foreach (ResourceType resourceType in resourceTypeSet) + { + _resourceTypesByClrType.Add(resourceType.ClrType, resourceType); + _resourceTypesByPublicName.Add(resourceType.PublicName, resourceType); } + } + + /// + public IReadOnlySet GetResourceTypes() + { + return _resourceTypeSet; + } - /// - public IReadOnlyCollection GetResourceContexts() + /// + public ResourceType GetResourceType(string publicName) + { + ResourceType? resourceType = FindResourceType(publicName); + + if (resourceType == null) { - return _resources; + throw new InvalidOperationException($"Resource type '{publicName}' does not exist in the resource graph."); } - /// - public ResourceContext GetResourceContext(string resourceName) - { - ArgumentGuard.NotNullNorEmpty(resourceName, nameof(resourceName)); + return resourceType; + } - return _resources.SingleOrDefault(resourceContext => resourceContext.PublicName == resourceName); - } + /// + public ResourceType? FindResourceType(string publicName) + { + ArgumentNullException.ThrowIfNull(publicName); - /// - public ResourceContext GetResourceContext(Type resourceType) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + return _resourceTypesByPublicName.GetValueOrDefault(publicName); + } - return IsLazyLoadingProxyForResourceType(resourceType) - ? _resources.SingleOrDefault(resourceContext => resourceContext.ResourceType == resourceType.BaseType) - : _resources.SingleOrDefault(resourceContext => resourceContext.ResourceType == resourceType); - } + /// + public ResourceType GetResourceType(Type resourceClrType) + { + ResourceType? resourceType = FindResourceType(resourceClrType); - /// - public ResourceContext GetResourceContext() - where TResource : class, IIdentifiable + if (resourceType == null) { - return GetResourceContext(typeof(TResource)); + throw new InvalidOperationException($"Type '{resourceClrType}' does not exist in the resource graph."); } - /// - public IReadOnlyCollection GetFields(Expression> selector = null) - where TResource : class, IIdentifiable - { - return Getter(selector); - } + return resourceType; + } - /// - public IReadOnlyCollection GetAttributes(Expression> selector = null) - where TResource : class, IIdentifiable - { - return Getter(selector, FieldFilterType.Attribute).Cast().ToArray(); - } + /// + public ResourceType? FindResourceType(Type resourceClrType) + { + ArgumentNullException.ThrowIfNull(resourceClrType); - /// - public IReadOnlyCollection GetRelationships(Expression> selector = null) - where TResource : class, IIdentifiable - { - return Getter(selector, FieldFilterType.Relationship).Cast().ToArray(); - } + Type typeToFind = IsLazyLoadingProxyForResourceType(resourceClrType) ? resourceClrType.BaseType! : resourceClrType; + return _resourceTypesByClrType.GetValueOrDefault(typeToFind); + } - /// - public IReadOnlyCollection GetFields(Type type) - { - ArgumentGuard.NotNull(type, nameof(type)); + private bool IsLazyLoadingProxyForResourceType(Type resourceClrType) + { + return ProxyTargetAccessorType?.IsAssignableFrom(resourceClrType) ?? false; + } - return GetResourceContext(type).Fields; - } + /// + public ResourceType GetResourceType() + where TResource : class, IIdentifiable + { + return GetResourceType(typeof(TResource)); + } - /// - public IReadOnlyCollection GetAttributes(Type type) - { - ArgumentGuard.NotNull(type, nameof(type)); + /// + public IReadOnlyCollection GetFields(Expression> selector) + where TResource : class, IIdentifiable + { + ArgumentNullException.ThrowIfNull(selector); - return GetResourceContext(type).Attributes; - } + return FilterFields(selector); + } - /// - public IReadOnlyCollection GetRelationships(Type type) - { - ArgumentGuard.NotNull(type, nameof(type)); + /// + public IReadOnlyCollection GetAttributes(Expression> selector) + where TResource : class, IIdentifiable + { + ArgumentNullException.ThrowIfNull(selector); - return GetResourceContext(type).Relationships; - } + return FilterFields(selector); + } - /// - public RelationshipAttribute GetInverseRelationship(RelationshipAttribute relationship) - { - ArgumentGuard.NotNull(relationship, nameof(relationship)); + /// + public IReadOnlyCollection GetRelationships(Expression> selector) + where TResource : class, IIdentifiable + { + ArgumentNullException.ThrowIfNull(selector); - if (relationship.InverseNavigationProperty == null) - { - return null; - } + return FilterFields(selector); + } - return GetResourceContext(relationship.RightType).Relationships - .SingleOrDefault(nextRelationship => nextRelationship.Property == relationship.InverseNavigationProperty); - } + private ReadOnlyCollection FilterFields(Expression> selector) + where TResource : class, IIdentifiable + where TField : ResourceFieldAttribute + { + IReadOnlyCollection source = GetFieldsOfType(); + List matches = []; - private IReadOnlyCollection Getter(Expression> selector = null, - FieldFilterType type = FieldFilterType.None) - where TResource : class, IIdentifiable + foreach (string memberName in ToMemberNames(selector)) { - IReadOnlyCollection available; + TField? matchingField = source.FirstOrDefault(field => field.Property.Name == memberName); - if (type == FieldFilterType.Attribute) - { - available = GetResourceContext(typeof(TResource)).Attributes; - } - else if (type == FieldFilterType.Relationship) - { - available = GetResourceContext(typeof(TResource)).Relationships; - } - else + if (matchingField == null) { - available = GetResourceContext(typeof(TResource)).Fields; + throw new ArgumentException($"Member '{memberName}' is not exposed as a JSON:API field.", nameof(selector)); } - if (selector == null) - { - return available; - } - - var targeted = new List(); - - Expression selectorBody = RemoveConvert(selector.Body); + matches.Add(matchingField); + } - if (selectorBody is MemberExpression memberExpression) - { - // model => model.Field1 - try - { - targeted.Add(available.Single(field => field.Property.Name == memberExpression.Member.Name)); - return targeted; - } - catch (InvalidOperationException) - { - ThrowNotExposedError(memberExpression.Member.Name, type); - } - } + return matches.AsReadOnly(); + } - if (selectorBody is NewExpression newExpression) - { - // model => new { model.Field1, model.Field2 } - string memberName = null; - - try - { - if (newExpression.Members == null) - { - return targeted; - } - - foreach (MemberInfo member in newExpression.Members) - { - memberName = member.Name; - targeted.Add(available.Single(field => field.Property.Name == memberName)); - } - - return targeted; - } - catch (InvalidOperationException) - { - ThrowNotExposedError(memberName, type); - } - } + private IReadOnlyCollection GetFieldsOfType() + where TKind : ResourceFieldAttribute + { + ResourceType resourceType = GetResourceType(typeof(TResource)); - 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 }'."); + if (typeof(TKind) == typeof(AttrAttribute)) + { + return (IReadOnlyCollection)resourceType.Attributes; } - private bool IsLazyLoadingProxyForResourceType(Type resourceType) + if (typeof(TKind) == typeof(RelationshipAttribute)) { - return ProxyTargetAccessorType?.IsAssignableFrom(resourceType) ?? false; + return (IReadOnlyCollection)resourceType.Relationships; } - private static Expression RemoveConvert(Expression expression) + return (IReadOnlyCollection)resourceType.Fields; + } + + private IEnumerable ToMemberNames(Expression> selector) + { + Expression selectorBody = RemoveConvert(selector.Body); + + if (selectorBody is MemberExpression memberExpression) { - Expression innerExpression = expression; + // model => model.Field1 - while (true) + yield return memberExpression.Member.Name; + } + else if (selectorBody is NewExpression newExpression) + { + // model => new { model.Field1, model.Field2 } + + foreach (MemberInfo member in newExpression.Members ?? Enumerable.Empty()) { - if (innerExpression is UnaryExpression { NodeType: ExpressionType.Convert } unaryExpression) - { - innerExpression = unaryExpression.Operand; - } - else - { - return innerExpression; - } + yield return member.Name; } } - - private void ThrowNotExposedError(string memberName, FieldFilterType type) + else { - throw new ArgumentException($"{memberName} is not a JSON:API exposed {type:g}."); + 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)); } + } + + private static Expression RemoveConvert(Expression expression) + { + Expression innerExpression = expression; - private enum FieldFilterType + while (true) { - None, - Attribute, - Relationship + if (innerExpression is UnaryExpression { NodeType: ExpressionType.Convert } unaryExpression) + { + innerExpression = unaryExpression.Operand; + } + else + { + return innerExpression; + } } } } diff --git a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs index 0451c83643..8ab2120d92 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceGraphBuilder.cs @@ -1,363 +1,484 @@ -using System; -using System.Collections.Generic; -using System.Linq; +using System.Collections.ObjectModel; using System.Reflection; using JetBrains.Annotations; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +/// +/// Builds and configures the . +/// +[PublicAPI] +public partial class ResourceGraphBuilder { + private readonly IJsonApiOptions _options; + private readonly ILogger _logger; + private readonly Dictionary _resourceTypesByClrType = []; + private readonly TypeLocator _typeLocator = new(); + + public ResourceGraphBuilder(IJsonApiOptions options, ILoggerFactory loggerFactory) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(loggerFactory); + + _options = options; + _logger = loggerFactory.CreateLogger(); + } + /// - /// Builds and configures the . + /// Constructs the . /// - [PublicAPI] - public class ResourceGraphBuilder + public IResourceGraph Build() { - private readonly IJsonApiOptions _options; - private readonly ILogger _logger; - private readonly List _resources = new List(); - private readonly TypeLocator _typeLocator = new TypeLocator(); + IReadOnlySet resourceTypes = _resourceTypesByClrType.Values.ToHashSet().AsReadOnly(); - public ResourceGraphBuilder(IJsonApiOptions options, ILoggerFactory loggerFactory) + if (resourceTypes.Count == 0) { - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); - - _options = options; - _logger = loggerFactory.CreateLogger(); + LogResourceGraphIsEmpty(); } - /// - /// Constructs the . - /// - public IResourceGraph Build() + var resourceGraph = new ResourceGraph(resourceTypes); + + SetFieldTypes(resourceGraph); + SetRelationshipTypes(resourceGraph); + SetDirectlyDerivedTypes(resourceGraph); + ValidateFieldsInDerivedTypes(resourceGraph); + + return resourceGraph; + } + + private static void SetFieldTypes(ResourceGraph resourceGraph) + { + foreach (ResourceFieldAttribute field in resourceGraph.GetResourceTypes().SelectMany(resourceType => resourceType.Fields)) { - _resources.ForEach(SetResourceLinksOptions); - return new ResourceGraph(_resources); + field.Type = resourceGraph.GetResourceType(field.Property.ReflectedType!); } + } - private void SetResourceLinksOptions(ResourceContext resourceContext) + private static void SetRelationshipTypes(ResourceGraph resourceGraph) + { + foreach (RelationshipAttribute relationship in resourceGraph.GetResourceTypes().SelectMany(resourceType => resourceType.Relationships)) { - var attribute = (ResourceLinksAttribute)resourceContext.ResourceType.GetCustomAttribute(typeof(ResourceLinksAttribute)); + Type rightClrType = relationship is HasOneAttribute + ? relationship.Property.PropertyType + : relationship.Property.PropertyType.GetGenericArguments()[0]; - if (attribute != null) + ResourceType? rightType = resourceGraph.FindResourceType(rightClrType); + + if (rightType == null) { - resourceContext.RelationshipLinks = attribute.RelationshipLinks; - resourceContext.ResourceLinks = attribute.ResourceLinks; - resourceContext.TopLevelLinks = attribute.TopLevelLinks; + throw new InvalidConfigurationException( + $"Resource type '{relationship.LeftType.ClrType}' depends on '{rightClrType}', which was not added to the resource graph."); } + + relationship.RightType = rightType; } + } + + private static void SetDirectlyDerivedTypes(ResourceGraph resourceGraph) + { + Dictionary> directlyDerivedTypesPerBaseType = []; - /// - /// Adds a JSON:API resource with int as the identifier type. - /// - /// - /// The resource model type. - /// - /// - /// The name under which the resource is publicly exposed by the API. If nothing is specified, the configured naming convention formatter will be - /// applied. - /// - public ResourceGraphBuilder Add(string publicName = null) - where TResource : class, IIdentifiable + foreach (ResourceType resourceType in resourceGraph.GetResourceTypes()) { - return Add(publicName); + ResourceType? baseType = resourceGraph.FindResourceType(resourceType.ClrType.BaseType!); + + if (baseType != null) + { + resourceType.BaseType = baseType; + + if (!directlyDerivedTypesPerBaseType.TryGetValue(baseType, out HashSet? directlyDerivedTypes)) + { + directlyDerivedTypes = []; + directlyDerivedTypesPerBaseType[baseType] = directlyDerivedTypes; + } + + directlyDerivedTypes.Add(resourceType); + } } - /// - /// Adds a JSON:API resource. - /// - /// - /// The resource model type. - /// - /// - /// The resource model identifier type. - /// - /// - /// The name under which the resource is publicly exposed by the API. If nothing is specified, the configured naming convention formatter will be - /// applied. - /// - public ResourceGraphBuilder Add(string publicName = null) - where TResource : class, IIdentifiable + foreach ((ResourceType baseType, HashSet directlyDerivedTypes) in directlyDerivedTypesPerBaseType) { - return Add(typeof(TResource), typeof(TId), publicName); + if (directlyDerivedTypes.Count > 0) + { + baseType.DirectlyDerivedTypes = directlyDerivedTypes.AsReadOnly(); + } } + } - /// - /// Adds a JSON:API resource. - /// - /// - /// The resource model type. - /// - /// - /// The resource model identifier type. - /// - /// - /// The name under which the resource is publicly exposed by the API. If nothing is specified, the configured naming convention formatter will be - /// applied. - /// - public ResourceGraphBuilder Add(Type resourceType, Type idType = null, string publicName = null) + private void ValidateFieldsInDerivedTypes(ResourceGraph resourceGraph) + { + foreach (ResourceType resourceType in resourceGraph.GetResourceTypes()) { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - - if (_resources.Any(resourceContext => resourceContext.ResourceType == resourceType)) + if (resourceType.BaseType != null) { - return this; + ValidateAttributesInDerivedType(resourceType); + ValidateRelationshipsInDerivedType(resourceType); } + } + } - if (resourceType.IsOrImplementsInterface(typeof(IIdentifiable))) + private static void ValidateAttributesInDerivedType(ResourceType resourceType) + { + foreach (AttrAttribute attribute in resourceType.BaseType!.Attributes) + { + if (resourceType.FindAttributeByPublicName(attribute.PublicName) == null) { - string effectivePublicName = publicName ?? FormatResourceName(resourceType); - Type effectiveIdType = idType ?? _typeLocator.TryGetIdType(resourceType); + throw new InvalidConfigurationException( + $"Attribute '{attribute.PublicName}' from base type '{resourceType.BaseType.ClrType}' does not exist in derived type '{resourceType.ClrType}'."); + } + } + } - ResourceContext resourceContext = CreateResourceContext(effectivePublicName, resourceType, effectiveIdType); - _resources.Add(resourceContext); + private static void ValidateRelationshipsInDerivedType(ResourceType resourceType) + { + foreach (RelationshipAttribute relationship in resourceType.BaseType!.Relationships) + { + 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}'."); } - else + } + } + + public ResourceGraphBuilder Add(DbContext dbContext) + { + ArgumentNullException.ThrowIfNull(dbContext); + + foreach (IEntityType entityType in dbContext.Model.GetEntityTypes()) + { + if (!IsImplicitManyToManyJoinEntity(entityType)) { - _logger.LogWarning($"Entity '{resourceType}' does not implement '{nameof(IIdentifiable)}'."); + Add(entityType.ClrType); } + } + + return this; + } + + private static bool IsImplicitManyToManyJoinEntity(IEntityType entityType) + { + return entityType is { IsPropertyBag: true, HasSharedClrType: true }; + } + + /// + /// Adds a JSON:API resource. + /// + /// + /// The resource CLR type. + /// + /// + /// The resource identifier CLR type. + /// + /// + /// The name under which the resource is publicly exposed by the API. If nothing is specified, the naming convention is applied on the pluralized CLR + /// type name. + /// +#pragma warning disable AV1553 // Do not use optional parameters with default value null for strings, collections or tasks + public ResourceGraphBuilder Add(string? publicName = null) + where TResource : class, IIdentifiable +#pragma warning restore AV1553 // Do not use optional parameters with default value null for strings, collections or tasks + { + return Add(typeof(TResource), typeof(TId), publicName); + } + + /// + /// Adds a JSON:API resource. + /// + /// + /// The resource CLR type. + /// + /// + /// The resource identifier CLR type. + /// + /// + /// The name under which the resource is publicly exposed by the API. If nothing is specified, the naming convention is applied on the pluralized CLR + /// type name. + /// +#pragma warning disable AV1553 // Do not use optional parameters with default value null for strings, collections or tasks + 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 + { + ArgumentNullException.ThrowIfNull(resourceClrType); + if (_resourceTypesByClrType.ContainsKey(resourceClrType)) + { return this; } - private ResourceContext CreateResourceContext(string publicName, Type resourceType, Type idType) + if (resourceClrType.IsOrImplementsInterface()) { - return new ResourceContext + string effectivePublicName = publicName ?? FormatResourceName(resourceClrType); + Type? effectiveIdType = idClrType ?? _typeLocator.LookupIdType(resourceClrType); + + if (effectiveIdType == null) { - PublicName = publicName, - ResourceType = resourceType, - IdentityType = idType, - Attributes = GetAttributes(resourceType), - Relationships = GetRelationships(resourceType), - EagerLoads = GetEagerLoads(resourceType) - }; - } + throw new InvalidConfigurationException($"Resource type '{resourceClrType}' implements 'IIdentifiable', but not 'IIdentifiable'."); + } - private IReadOnlyCollection GetAttributes(Type resourceType) - { - var attributes = new List(); + ResourceType resourceType = CreateResourceType(effectivePublicName, resourceClrType, effectiveIdType); - foreach (PropertyInfo property in resourceType.GetProperties()) + AssertNoDuplicatePublicName(resourceType, effectivePublicName); + + _resourceTypesByClrType.Add(resourceClrType, resourceType); + } + else + { + if (resourceClrType.GetCustomAttribute() == null) { - var attribute = (AttrAttribute)property.GetCustomAttribute(typeof(AttrAttribute)); + LogResourceTypeDoesNotImplementInterface(resourceClrType, nameof(IIdentifiable)); + } + } - // Although strictly not correct, 'id' is added to the list of attributes for convenience. - // For example, it enables to filter on ID, without the need to special-case existing logic. - // And when using sparse fields, it silently adds 'id' to the set of attributes to retrieve. - if (property.Name == nameof(Identifiable.Id) && attribute == null) - { - var idAttr = new AttrAttribute - { - PublicName = FormatPropertyName(property), - Property = property, - Capabilities = _options.DefaultAttrCapabilities - }; - - attributes.Add(idAttr); - continue; - } + return this; + } - if (attribute == null) - { - continue; - } + private ResourceType CreateResourceType(string publicName, Type resourceClrType, Type idClrType) + { + ClientIdGenerationMode? clientIdGeneration = GetClientIdGeneration(resourceClrType); - attribute.PublicName ??= FormatPropertyName(property); - attribute.Property = property; + Dictionary.ValueCollection attributes = GetAttributes(resourceClrType); + Dictionary.ValueCollection relationships = GetRelationships(resourceClrType); + ReadOnlyCollection eagerLoads = GetEagerLoads(resourceClrType); - if (!attribute.HasExplicitCapabilities) - { - attribute.Capabilities = _options.DefaultAttrCapabilities; - } + AssertNoDuplicatePublicName(attributes, relationships); - attributes.Add(attribute); - } + var linksAttribute = resourceClrType.GetCustomAttribute(true); - return attributes; - } + return linksAttribute == null + ? new ResourceType(publicName, clientIdGeneration, resourceClrType, idClrType, attributes, relationships, eagerLoads) + : new ResourceType(publicName, clientIdGeneration, resourceClrType, idClrType, attributes, relationships, eagerLoads, linksAttribute.TopLevelLinks, + linksAttribute.ResourceLinks, linksAttribute.RelationshipLinks); + } + + private ClientIdGenerationMode? GetClientIdGeneration(Type resourceClrType) + { + var resourceAttribute = resourceClrType.GetCustomAttribute(true); + return resourceAttribute?.NullableClientIdGeneration; + } - private IReadOnlyCollection GetRelationships(Type resourceType) + private Dictionary.ValueCollection GetAttributes(Type resourceClrType) + { + var attributesByName = new Dictionary(); + + foreach (PropertyInfo property in resourceClrType.GetProperties()) { - var attributes = new List(); - PropertyInfo[] properties = resourceType.GetProperties(); + var attribute = property.GetCustomAttribute(true); - foreach (PropertyInfo prop in properties) + if (attribute == null) { - var attribute = (RelationshipAttribute)prop.GetCustomAttribute(typeof(RelationshipAttribute)); + if (property.Name == nameof(Identifiable.Id)) + { + // Although strictly not correct, 'id' is added to the list of attributes for convenience. + // For example, it enables to filter on ID, without the need to special-case existing logic. + // And when using sparse fieldsets, it silently adds 'id' to the set of attributes to retrieve. - if (attribute == null) + attribute = new AttrAttribute(); + } + else { continue; } + } - attribute.Property = prop; - attribute.PublicName ??= FormatPropertyName(prop); - attribute.RightType = GetRelationshipType(attribute, prop); - attribute.LeftType = resourceType; - attributes.Add(attribute); + SetPublicName(attribute, property); + attribute.Property = property; - if (attribute is HasManyThroughAttribute hasManyThroughAttribute) - { - PropertyInfo throughProperty = properties.SingleOrDefault(property => property.Name == hasManyThroughAttribute.ThroughPropertyName); - - if (throughProperty == null) - { - throw new InvalidConfigurationException($"Invalid {nameof(HasManyThroughAttribute)} on '{resourceType}.{attribute.Property.Name}': " + - $"Resource does not contain a property named '{hasManyThroughAttribute.ThroughPropertyName}'."); - } - - Type throughType = TryGetThroughType(throughProperty); - - if (throughType == null) - { - throw new InvalidConfigurationException($"Invalid {nameof(HasManyThroughAttribute)} on '{resourceType}.{attribute.Property.Name}': " + - $"Referenced property '{throughProperty.Name}' does not implement 'ICollection'."); - } - - // ICollection - hasManyThroughAttribute.ThroughProperty = throughProperty; - - // ArticleTag - hasManyThroughAttribute.ThroughType = throughType; - - PropertyInfo[] throughProperties = throughType.GetProperties(); - - // ArticleTag.Article - if (hasManyThroughAttribute.LeftPropertyName != null) - { - // In case of a self-referencing many-to-many relationship, the left property name must be specified. - hasManyThroughAttribute.LeftProperty = hasManyThroughAttribute.ThroughType.GetProperty(hasManyThroughAttribute.LeftPropertyName) ?? - throw new InvalidConfigurationException( - $"'{throughType}' does not contain a navigation property named '{hasManyThroughAttribute.LeftPropertyName}'."); - } - else - { - // In case of a non-self-referencing many-to-many relationship, we just pick the single compatible type. - hasManyThroughAttribute.LeftProperty = - throughProperties.SingleOrDefault(property => property.PropertyType.IsAssignableFrom(resourceType)) ?? - throw new InvalidConfigurationException($"'{throughType}' does not contain a navigation property to type '{resourceType}'."); - } - - // ArticleTag.ArticleId - string leftIdPropertyName = hasManyThroughAttribute.LeftIdPropertyName ?? hasManyThroughAttribute.LeftProperty.Name + "Id"; - - hasManyThroughAttribute.LeftIdProperty = throughProperties.SingleOrDefault(property => property.Name == leftIdPropertyName) ?? - throw new InvalidConfigurationException( - $"'{throughType}' does not contain a relationship ID property to type '{resourceType}' with name '{leftIdPropertyName}'."); - - // ArticleTag.Tag - if (hasManyThroughAttribute.RightPropertyName != null) - { - // In case of a self-referencing many-to-many relationship, the right property name must be specified. - hasManyThroughAttribute.RightProperty = hasManyThroughAttribute.ThroughType.GetProperty(hasManyThroughAttribute.RightPropertyName) ?? - throw new InvalidConfigurationException( - $"'{throughType}' does not contain a navigation property named '{hasManyThroughAttribute.RightPropertyName}'."); - } - else - { - // In case of a non-self-referencing many-to-many relationship, we just pick the single compatible type. - hasManyThroughAttribute.RightProperty = - throughProperties.SingleOrDefault(property => property.PropertyType == hasManyThroughAttribute.RightType) ?? - throw new InvalidConfigurationException( - $"'{throughType}' does not contain a navigation property to type '{hasManyThroughAttribute.RightType}'."); - } - - // ArticleTag.TagId - string rightIdPropertyName = hasManyThroughAttribute.RightIdPropertyName ?? hasManyThroughAttribute.RightProperty.Name + "Id"; - - hasManyThroughAttribute.RightIdProperty = throughProperties.SingleOrDefault(property => property.Name == rightIdPropertyName) ?? - throw new InvalidConfigurationException( - $"'{throughType}' does not contain a relationship ID property to type '{hasManyThroughAttribute.RightType}' with name '{rightIdPropertyName}'."); - } + if (!attribute.HasExplicitCapabilities) + { + attribute.Capabilities = _options.DefaultAttrCapabilities; } - return attributes; + IncludeField(attributesByName, attribute); } - private Type TryGetThroughType(PropertyInfo throughProperty) + if (attributesByName.Count < 2) { - if (throughProperty.PropertyType.IsGenericType) - { - Type[] typeArguments = throughProperty.PropertyType.GetGenericArguments(); + LogResourceTypeHasNoAttributes(resourceClrType); + } - if (typeArguments.Length == 1) - { - Type constructedThroughType = typeof(ICollection<>).MakeGenericType(typeArguments[0]); + return attributesByName.Values; + } - if (throughProperty.PropertyType.IsOrImplementsInterface(constructedThroughType)) - { - return typeArguments[0]; - } - } + private Dictionary.ValueCollection GetRelationships(Type resourceClrType) + { + var relationshipsByName = new Dictionary(); + PropertyInfo[] properties = resourceClrType.GetProperties(); + + foreach (PropertyInfo property in properties) + { + var relationship = property.GetCustomAttribute(true); + + if (relationship != null) + { + relationship.Property = property; + SetPublicName(relationship, property); + SetRelationshipCapabilities(relationship); + + IncludeField(relationshipsByName, relationship); } + } + + return relationshipsByName.Values; + } + + private void SetPublicName(ResourceFieldAttribute field, PropertyInfo property) + { + // ReSharper disable once NullCoalescingConditionIsAlwaysNotNullAccordingToAPIContract + field.PublicName ??= FormatPropertyName(property); + } - return null; + 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 Type GetRelationshipType(RelationshipAttribute relationship, PropertyInfo property) + private void SetHasOneRelationshipCapabilities(HasOneAttribute hasOneRelationship, bool canInclude) + { + if (!hasOneRelationship.HasExplicitCapabilities) { - ArgumentGuard.NotNull(relationship, nameof(relationship)); - ArgumentGuard.NotNull(property, nameof(property)); + hasOneRelationship.Capabilities = _options.DefaultHasOneCapabilities; + } - return relationship is HasOneAttribute ? property.PropertyType : property.PropertyType.GetGenericArguments()[0]; + if (hasOneRelationship.HasExplicitCanInclude) + { + hasOneRelationship.Capabilities = canInclude + ? hasOneRelationship.Capabilities | HasOneCapabilities.AllowInclude + : hasOneRelationship.Capabilities & ~HasOneCapabilities.AllowInclude; } + } - private IReadOnlyCollection GetEagerLoads(Type resourceType, int recursionDepth = 0) + private void SetHasManyRelationshipCapabilities(HasManyAttribute hasManyRelationship, bool canInclude) + { + if (!hasManyRelationship.HasExplicitCapabilities) { - AssertNoInfiniteRecursion(recursionDepth); + hasManyRelationship.Capabilities = _options.DefaultHasManyCapabilities; + } - var attributes = new List(); - PropertyInfo[] properties = resourceType.GetProperties(); + if (hasManyRelationship.HasExplicitCanInclude) + { + hasManyRelationship.Capabilities = canInclude + ? hasManyRelationship.Capabilities | HasManyCapabilities.AllowInclude + : hasManyRelationship.Capabilities & ~HasManyCapabilities.AllowInclude; + } + } - foreach (PropertyInfo property in properties) - { - var attribute = (EagerLoadAttribute)property.GetCustomAttribute(typeof(EagerLoadAttribute)); + private ReadOnlyCollection GetEagerLoads(Type resourceClrType, int recursionDepth = 0) + { + AssertNoInfiniteRecursion(recursionDepth); - if (attribute == null) - { - continue; - } + List eagerLoads = []; + PropertyInfo[] properties = resourceClrType.GetProperties(); - Type innerType = TypeOrElementType(property.PropertyType); - attribute.Children = GetEagerLoads(innerType, recursionDepth + 1); - attribute.Property = property; + foreach (PropertyInfo property in properties) + { + var eagerLoad = property.GetCustomAttribute(true); - attributes.Add(attribute); + if (eagerLoad == null) + { + continue; } - return attributes; + Type rightType = CollectionConverter.Instance.FindCollectionElementType(property.PropertyType) ?? property.PropertyType; + eagerLoad.Children = GetEagerLoads(rightType, recursionDepth + 1); + eagerLoad.Property = property; + + eagerLoads.Add(eagerLoad); } - [AssertionMethod] - private static void AssertNoInfiniteRecursion(int recursionDepth) + return eagerLoads.AsReadOnly(); + } + + private static void IncludeField(Dictionary fieldsByName, TField field) + where TField : ResourceFieldAttribute + { + if (fieldsByName.TryGetValue(field.PublicName, out TField? existingField)) { - if (recursionDepth >= 500) - { - throw new InvalidOperationException("Infinite recursion detected in eager-load chain."); - } + throw CreateExceptionForDuplicatePublicName(field.Property.DeclaringType!, existingField, field); } - private Type TypeOrElementType(Type type) - { - Type[] interfaces = type.GetInterfaces() - .Where(@interface => @interface.IsGenericType && @interface.GetGenericTypeDefinition() == typeof(IEnumerable<>)).ToArray(); + fieldsByName.Add(field.PublicName, field); + } - return interfaces.Length == 1 ? interfaces.Single().GenericTypeArguments[0] : type; + private void AssertNoDuplicatePublicName(ResourceType resourceType, string effectivePublicName) + { + (Type? existingClrType, _) = _resourceTypesByClrType.FirstOrDefault(type => type.Value.PublicName == resourceType.PublicName); + + if (existingClrType != null) + { + throw new InvalidConfigurationException($"Resource '{existingClrType}' and '{resourceType.ClrType}' both use public name '{effectivePublicName}'."); } + } + + private void AssertNoDuplicatePublicName(IReadOnlyCollection attributes, IReadOnlyCollection relationships) + { + IEnumerable<(AttrAttribute attribute, RelationshipAttribute relationship)> query = + from attribute in attributes + from relationship in relationships + where attribute.PublicName == relationship.PublicName + select (attribute, relationship); - private string FormatResourceName(Type resourceType) + (AttrAttribute? duplicateAttribute, RelationshipAttribute? duplicateRelationship) = query.FirstOrDefault(); + + if (duplicateAttribute != null && duplicateRelationship != null) { - var formatter = new ResourceNameFormatter(_options.SerializerNamingStrategy); - return formatter.FormatResourceName(resourceType); + throw CreateExceptionForDuplicatePublicName(duplicateAttribute.Property.DeclaringType!, duplicateAttribute, duplicateRelationship); } + } + + private static InvalidConfigurationException CreateExceptionForDuplicatePublicName(Type containingClrType, ResourceFieldAttribute existingField, + ResourceFieldAttribute field) + { + return new InvalidConfigurationException( + $"Properties '{containingClrType}.{existingField.Property.Name}' and '{containingClrType}.{field.Property.Name}' both use public name '{field.PublicName}'."); + } - private string FormatPropertyName(PropertyInfo resourceProperty) + [AssertionMethod] + private static void AssertNoInfiniteRecursion(int recursionDepth) + { + if (recursionDepth >= 500) { - return _options.SerializerNamingStrategy.GetPropertyName(resourceProperty.Name, false); + throw new InvalidConfigurationException("Infinite recursion detected in eager-load chain."); } } + + private string FormatResourceName(Type resourceClrType) + { + var formatter = new ResourceNameFormatter(_options.SerializerOptions.PropertyNamingPolicy); + return formatter.FormatResourceName(resourceClrType); + } + + private string FormatPropertyName(PropertyInfo resourceProperty) + { + return _options.SerializerOptions.PropertyNamingPolicy == null + ? 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 70963bb1a3..0531c25ca9 100644 --- a/src/JsonApiDotNetCore/Configuration/ResourceNameFormatter.cs +++ b/src/JsonApiDotNetCore/Configuration/ResourceNameFormatter.cs @@ -1,28 +1,29 @@ -using System; using System.Reflection; +using System.Text.Json; using Humanizer; using JsonApiDotNetCore.Resources.Annotations; -using Newtonsoft.Json.Serialization; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +internal sealed class ResourceNameFormatter(JsonNamingPolicy? namingPolicy) { - internal sealed class ResourceNameFormatter + 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) { - private readonly NamingStrategy _namingStrategy; + ArgumentNullException.ThrowIfNull(resourceClrType); - public ResourceNameFormatter(NamingStrategy namingStrategy) - { - _namingStrategy = namingStrategy; - } + var resourceAttribute = resourceClrType.GetCustomAttribute(true); - /// - /// Gets the publicly visible resource name for the internal type name using the configured naming convention. - /// - public string FormatResourceName(Type resourceType) + if (resourceAttribute != null && !string.IsNullOrWhiteSpace(resourceAttribute.PublicName)) { - return resourceType.GetCustomAttribute(typeof(ResourceAttribute)) is ResourceAttribute attribute - ? attribute.PublicName - : _namingStrategy.GetPropertyName(resourceType.Name.Pluralize(), false); + return resourceAttribute.PublicName; } + + string publicName = resourceClrType.Name.Pluralize(); + return _namingPolicy != null ? _namingPolicy.ConvertName(publicName) : publicName; } } 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 815b44bd96..65bf465ce3 100644 --- a/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceCollectionExtensions.cs @@ -1,175 +1,138 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; using JetBrains.Annotations; using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Building; -using JsonApiDotNetCore.Serialization.Client.Internal; using JsonApiDotNetCore.Services; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +[PublicAPI] +public static class ServiceCollectionExtensions { - [PublicAPI] - public static class ServiceCollectionExtensions + private static readonly TypeLocator TypeLocator = new(); + + /// + /// Configures JsonApiDotNetCore by registering resources manually. + /// +#pragma warning disable AV1553 // Do not use optional parameters with default value null for strings, collections or tasks + public static IServiceCollection AddJsonApi(this IServiceCollection services, Action? options = null, + Action? discovery = null, Action? resources = null, IMvcCoreBuilder? mvcBuilder = null, + ICollection? dbContextTypes = null) +#pragma warning restore AV1553 // Do not use optional parameters with default value null for strings, collections or tasks { - private static readonly TypeLocator TypeLocator = new TypeLocator(); - - /// - /// Configures JsonApiDotNetCore by registering resources manually. - /// - public static IServiceCollection AddJsonApi(this IServiceCollection services, Action options = null, - Action discovery = null, Action resources = null, IMvcCoreBuilder mvcBuilder = null, - ICollection dbContextTypes = null) - { - ArgumentGuard.NotNull(services, nameof(services)); - - SetupApplicationBuilder(services, options, discovery, resources, mvcBuilder, dbContextTypes ?? Array.Empty()); + ArgumentNullException.ThrowIfNull(services); - return services; - } + SetupApplicationBuilder(services, options, discovery, resources, mvcBuilder, dbContextTypes ?? Array.Empty()); - /// - /// Configures JsonApiDotNetCore by registering resources from an Entity Framework Core model. - /// - public static IServiceCollection AddJsonApi(this IServiceCollection services, Action options = null, - Action discovery = null, Action resources = null, IMvcCoreBuilder mvcBuilder = null) - where TDbContext : DbContext - { - return AddJsonApi(services, options, discovery, resources, mvcBuilder, typeof(TDbContext).AsArray()); - } - - private static void SetupApplicationBuilder(IServiceCollection services, Action configureOptions, - Action configureAutoDiscovery, Action configureResourceGraph, IMvcCoreBuilder mvcBuilder, - ICollection dbContextTypes) - { - using var applicationBuilder = new JsonApiApplicationBuilder(services, mvcBuilder ?? services.AddMvcCore()); - - applicationBuilder.ConfigureJsonApiOptions(configureOptions); - applicationBuilder.ConfigureAutoDiscovery(configureAutoDiscovery); - applicationBuilder.AddResourceGraph(dbContextTypes, configureResourceGraph); - applicationBuilder.ConfigureMvc(); - applicationBuilder.DiscoverInjectables(); - applicationBuilder.ConfigureServiceContainer(dbContextTypes); - } - - /// - /// Enables client serializers for sending requests and receiving responses in JSON:API format. Internally only used for testing. Will be extended in the - /// future to be part of a JsonApiClientDotNetCore package. - /// - public static IServiceCollection AddClientSerialization(this IServiceCollection services) - { - ArgumentGuard.NotNull(services, nameof(services)); + return services; + } - services.AddScoped(); + /// + /// Configures JsonApiDotNetCore by registering resources from an Entity Framework Core model. + /// + public static IServiceCollection AddJsonApi(this IServiceCollection services, Action? options = null, + Action? discovery = null, Action? resources = null, IMvcCoreBuilder? mvcBuilder = null) + where TDbContext : DbContext + { + return AddJsonApi(services, options, discovery, resources, mvcBuilder, [typeof(TDbContext)]); + } - services.AddScoped(sp => - { - var graph = sp.GetRequiredService(); - return new RequestSerializer(graph, new ResourceObjectBuilder(graph, new ResourceObjectBuilderSettings())); - }); + private static void SetupApplicationBuilder(IServiceCollection services, Action? configureOptions, + Action? configureAutoDiscovery, Action? configureResources, IMvcCoreBuilder? mvcBuilder, + ICollection dbContextTypes) + { + var applicationBuilder = new JsonApiApplicationBuilder(services, mvcBuilder ?? services.AddMvcCore()); + + applicationBuilder.ConfigureJsonApiOptions(configureOptions); + applicationBuilder.ConfigureAutoDiscovery(configureAutoDiscovery); + applicationBuilder.ConfigureResourceGraph(dbContextTypes, configureResources); + applicationBuilder.ConfigureMvc(); + applicationBuilder.DiscoverInjectables(); + applicationBuilder.ConfigureServiceContainer(dbContextTypes); + } - return services; - } + /// + /// Adds IoC container registrations for the various JsonApiDotNetCore resource service interfaces, such as , + /// and the various others. + /// + public static IServiceCollection AddResourceService(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); - /// - /// Adds IoC container registrations for the various JsonApiDotNetCore resource service interfaces, such as , - /// and the various others. - /// - public static IServiceCollection AddResourceService(this IServiceCollection services) - { - ArgumentGuard.NotNull(services, nameof(services)); + RegisterTypeForUnboundInterfaces(services, typeof(TService), InjectablesAssemblyScanner.ServiceUnboundInterfaces); - RegisterForConstructedType(services, typeof(TService), ServiceDiscoveryFacade.ServiceInterfaces); + return services; + } - return services; - } + /// + /// Adds IoC container registrations for the various JsonApiDotNetCore resource repository interfaces, such as + /// and . + /// + public static IServiceCollection AddResourceRepository(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); - /// - /// Adds IoC container registrations for the various JsonApiDotNetCore resource repository interfaces, such as - /// and . - /// - public static IServiceCollection AddResourceRepository(this IServiceCollection services) - { - ArgumentGuard.NotNull(services, nameof(services)); + RegisterTypeForUnboundInterfaces(services, typeof(TRepository), InjectablesAssemblyScanner.RepositoryUnboundInterfaces); - RegisterForConstructedType(services, typeof(TRepository), ServiceDiscoveryFacade.RepositoryInterfaces); + return services; + } - return services; - } + /// + /// Adds IoC container registrations for the various JsonApiDotNetCore resource definition interfaces, such as + /// . + /// + public static IServiceCollection AddResourceDefinition(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); - /// - /// Adds IoC container registrations for the various JsonApiDotNetCore resource definition interfaces, such as - /// and . - /// - public static IServiceCollection AddResourceDefinition(this IServiceCollection services) - { - ArgumentGuard.NotNull(services, nameof(services)); + RegisterTypeForUnboundInterfaces(services, typeof(TResourceDefinition), InjectablesAssemblyScanner.ResourceDefinitionUnboundInterfaces); - RegisterForConstructedType(services, typeof(TResourceDefinition), ServiceDiscoveryFacade.ResourceDefinitionInterfaces); + return services; + } - return services; - } + private static void RegisterTypeForUnboundInterfaces(IServiceCollection serviceCollection, Type implementationType, IEnumerable unboundInterfaces) + { + bool seenCompatibleInterface = false; + ResourceDescriptor? resourceDescriptor = ResolveResourceTypeFromServiceImplementation(implementationType); - private static void RegisterForConstructedType(IServiceCollection services, Type implementationType, IEnumerable openGenericInterfaces) + if (resourceDescriptor != null) { - bool seenCompatibleInterface = false; - ResourceDescriptor resourceDescriptor = TryGetResourceTypeFromServiceImplementation(implementationType); - - if (resourceDescriptor != null) + foreach (Type unboundInterface in unboundInterfaces) { - foreach (Type openGenericInterface in openGenericInterfaces) + Type closedInterface = unboundInterface.MakeGenericType(resourceDescriptor.ResourceClrType, resourceDescriptor.IdClrType); + + if (closedInterface.IsAssignableFrom(implementationType)) { - // A shorthand interface is one where the ID type is omitted. - // e.g. IResourceService is the shorthand for IResourceService - bool isShorthandInterface = openGenericInterface.GetTypeInfo().GenericTypeParameters.Length == 1; - - if (isShorthandInterface && resourceDescriptor.IdType != typeof(int)) - { - // We can't create a shorthand for ID types other than int. - continue; - } - - Type constructedType = isShorthandInterface - ? openGenericInterface.MakeGenericType(resourceDescriptor.ResourceType) - : openGenericInterface.MakeGenericType(resourceDescriptor.ResourceType, resourceDescriptor.IdType); - - if (constructedType.IsAssignableFrom(implementationType)) - { - services.AddScoped(constructedType, implementationType); - seenCompatibleInterface = true; - } + serviceCollection.AddScoped(closedInterface, implementationType); + seenCompatibleInterface = true; } } + } - if (!seenCompatibleInterface) - { - throw new InvalidConfigurationException($"{implementationType} does not implement any of the expected JsonApiDotNetCore interfaces."); - } + if (!seenCompatibleInterface) + { + throw new InvalidConfigurationException($"Type '{implementationType}' does not implement any of the expected JsonApiDotNetCore interfaces."); } + } - private static ResourceDescriptor TryGetResourceTypeFromServiceImplementation(Type serviceType) + private static ResourceDescriptor? ResolveResourceTypeFromServiceImplementation(Type? serviceType) + { + if (serviceType != null) { foreach (Type @interface in serviceType.GetInterfaces()) { - Type firstGenericArgument = @interface.IsGenericType ? @interface.GenericTypeArguments.First() : null; + Type? firstTypeArgument = @interface.IsGenericType ? @interface.GenericTypeArguments.First() : null; + ResourceDescriptor? resourceDescriptor = TypeLocator.ResolveResourceDescriptor(firstTypeArgument); - if (firstGenericArgument != null) + if (resourceDescriptor != null) { - ResourceDescriptor resourceDescriptor = TypeLocator.TryGetResourceDescriptor(firstGenericArgument); - - if (resourceDescriptor != null) - { - return resourceDescriptor; - } + return resourceDescriptor; } } - - return null; } + + return null; } } diff --git a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs index a15e7c60e3..d28b8381c6 100644 --- a/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs +++ b/src/JsonApiDotNetCore/Configuration/ServiceDiscoveryFacade.cs @@ -1,220 +1,39 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Reflection; using JetBrains.Annotations; -using JsonApiDotNetCore.Errors; -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. - /// - [PublicAPI] - public class ServiceDiscoveryFacade - { - internal static readonly HashSet ServiceInterfaces = new HashSet - { - typeof(IResourceService<>), - typeof(IResourceService<,>), - typeof(IResourceCommandService<>), - typeof(IResourceCommandService<,>), - typeof(IResourceQueryService<>), - typeof(IResourceQueryService<,>), - typeof(IGetAllService<>), - typeof(IGetAllService<,>), - typeof(IGetByIdService<>), - typeof(IGetByIdService<,>), - typeof(IGetSecondaryService<>), - typeof(IGetSecondaryService<,>), - typeof(IGetRelationshipService<>), - typeof(IGetRelationshipService<,>), - typeof(ICreateService<>), - typeof(ICreateService<,>), - typeof(IAddToRelationshipService<>), - typeof(IAddToRelationshipService<,>), - typeof(IUpdateService<>), - typeof(IUpdateService<,>), - typeof(ISetRelationshipService<>), - typeof(ISetRelationshipService<,>), - typeof(IDeleteService<>), - typeof(IDeleteService<,>), - typeof(IRemoveFromRelationshipService<>), - typeof(IRemoveFromRelationshipService<,>) - }; - - internal static readonly HashSet RepositoryInterfaces = new HashSet - { - typeof(IResourceRepository<>), - typeof(IResourceRepository<,>), - typeof(IResourceWriteRepository<>), - typeof(IResourceWriteRepository<,>), - typeof(IResourceReadRepository<>), - typeof(IResourceReadRepository<,>) - }; - - internal static readonly HashSet ResourceDefinitionInterfaces = new HashSet - { - typeof(IResourceDefinition<>), - typeof(IResourceDefinition<,>) - }; - - private readonly ILogger _logger; - private readonly IServiceCollection _services; - private readonly ResourceGraphBuilder _resourceGraphBuilder; - private readonly IJsonApiOptions _options; - private readonly ResourceDescriptorAssemblyCache _assemblyCache = new ResourceDescriptorAssemblyCache(); - private readonly TypeLocator _typeLocator = new TypeLocator(); - - public ServiceDiscoveryFacade(IServiceCollection services, ResourceGraphBuilder resourceGraphBuilder, IJsonApiOptions options, - ILoggerFactory loggerFactory) - { - ArgumentGuard.NotNull(services, nameof(services)); - ArgumentGuard.NotNull(resourceGraphBuilder, nameof(resourceGraphBuilder)); - ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); - ArgumentGuard.NotNull(options, nameof(options)); - - _logger = loggerFactory.CreateLogger(); - _services = services; - _resourceGraphBuilder = resourceGraphBuilder; - _options = options; - } - - /// - /// Mark the calling assembly for scanning of resources and injectables. - /// - public ServiceDiscoveryFacade AddCurrentAssembly() - { - return AddAssembly(Assembly.GetCallingAssembly()); - } - - /// - /// Mark the specified assembly for scanning of resources and injectables. - /// - public ServiceDiscoveryFacade AddAssembly(Assembly assembly) - { - ArgumentGuard.NotNull(assembly, nameof(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); +namespace JsonApiDotNetCore.Configuration; - if (_options.EnableResourceHooks) - { - AddResourceHookDefinitions(assembly, resourceDescriptor); - } - } - } - - private void AddDbContextResolvers(Assembly assembly) - { - IEnumerable dbContextTypes = _typeLocator.GetDerivedTypes(assembly, typeof(DbContext)); - - foreach (Type dbContextType in dbContextTypes) - { - Type resolverType = typeof(DbContextResolver<>).MakeGenericType(dbContextType); - _services.AddScoped(typeof(IDbContextResolver), resolverType); - } - } - - private void AddResource(ResourceDescriptor resourceDescriptor) - { - _resourceGraphBuilder.Add(resourceDescriptor.ResourceType, resourceDescriptor.IdType); - } - - private void AddResourceHookDefinitions(Assembly assembly, ResourceDescriptor identifiable) - { - try - { - Type resourceDefinition = _typeLocator.GetDerivedGenericTypes(assembly, typeof(ResourceHooksDefinition<>), identifiable.ResourceType) - .SingleOrDefault(); - - if (resourceDefinition != null) - { - _services.AddScoped(typeof(ResourceHooksDefinition<>).MakeGenericType(identifiable.ResourceType), resourceDefinition); - } - } - catch (InvalidOperationException exception) - { - throw new InvalidConfigurationException($"Cannot define multiple ResourceHooksDefinition<> implementations for '{identifiable.ResourceType}'", - exception); - } - } - - private void AddServices(Assembly assembly, ResourceDescriptor resourceDescriptor) - { - foreach (Type serviceInterface in ServiceInterfaces) - { - RegisterImplementations(assembly, serviceInterface, resourceDescriptor); - } - } +/// +/// Provides auto-discovery by scanning assemblies for resources and related injectables. +/// +[PublicAPI] +public sealed class ServiceDiscoveryFacade +{ + private readonly ResourceDescriptorAssemblyCache _assemblyCache; - private void AddRepositories(Assembly assembly, ResourceDescriptor resourceDescriptor) - { - foreach (Type repositoryInterface in RepositoryInterfaces) - { - RegisterImplementations(assembly, repositoryInterface, resourceDescriptor); - } - } + internal ServiceDiscoveryFacade(ResourceDescriptorAssemblyCache assemblyCache) + { + ArgumentNullException.ThrowIfNull(assemblyCache); - private void AddResourceDefinitions(Assembly assembly, ResourceDescriptor resourceDescriptor) - { - foreach (Type resourceDefinitionInterface in ResourceDefinitionInterfaces) - { - RegisterImplementations(assembly, resourceDefinitionInterface, resourceDescriptor); - } - } + _assemblyCache = assemblyCache; + } - private void RegisterImplementations(Assembly assembly, Type interfaceType, ResourceDescriptor resourceDescriptor) - { - Type[] genericArguments = interfaceType.GetTypeInfo().GenericTypeParameters.Length == 2 - ? ArrayFactory.Create(resourceDescriptor.ResourceType, resourceDescriptor.IdType) - : ArrayFactory.Create(resourceDescriptor.ResourceType); + /// + /// Includes the calling assembly for auto-discovery of resources and related injectables. + /// + public ServiceDiscoveryFacade AddCurrentAssembly() + { + return AddAssembly(Assembly.GetCallingAssembly()); + } - (Type implementation, Type registrationInterface)? result = - _typeLocator.GetGenericInterfaceImplementation(assembly, interfaceType, genericArguments); + /// + /// Includes the specified assembly for auto-discovery of resources and related injectables. + /// + public ServiceDiscoveryFacade AddAssembly(Assembly assembly) + { + ArgumentNullException.ThrowIfNull(assembly); - if (result != null) - { - (Type implementation, Type registrationInterface) = result.Value; - _services.AddScoped(registrationInterface, implementation); - } - } + _assemblyCache.RegisterAssembly(assembly); + return this; } } diff --git a/src/JsonApiDotNetCore/Configuration/TypeLocator.cs b/src/JsonApiDotNetCore/Configuration/TypeLocator.cs index 5aa436bc22..768f94a98d 100644 --- a/src/JsonApiDotNetCore/Configuration/TypeLocator.cs +++ b/src/JsonApiDotNetCore/Configuration/TypeLocator.cs @@ -1,157 +1,147 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Reflection; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Configuration +namespace JsonApiDotNetCore.Configuration; + +/// +/// Used to locate types and facilitate resource auto-discovery. +/// +internal sealed class TypeLocator { + // As a reminder, the following terminology is used for generic types: + // non-generic string + // generic + // unbound Dictionary<,> + // constructed + // open Dictionary + // closed Dictionary + /// - /// Used to locate types and facilitate resource auto-discovery. + /// Attempts to lookup the ID type of the specified resource type. Returns null if it does not implement . /// - internal sealed class TypeLocator + public Type? LookupIdType(Type? resourceClrType) { - /// - /// Attempts to lookup the ID type of the specified resource type. Returns null if it does not implement . - /// - public Type TryGetIdType(Type resourceType) - { - Type identifiableInterface = resourceType.GetInterfaces().FirstOrDefault(@interface => - @interface.IsGenericType && @interface.GetGenericTypeDefinition() == typeof(IIdentifiable<>)); + Type? identifiableClosedInterface = resourceClrType?.GetInterfaces().FirstOrDefault(@interface => + @interface.IsGenericType && @interface.GetGenericTypeDefinition() == typeof(IIdentifiable<>)); - return identifiableInterface?.GetGenericArguments()[0]; - } + return identifiableClosedInterface?.GetGenericArguments()[0]; + } - /// - /// Attempts to get a descriptor for the specified resource type. - /// - public ResourceDescriptor TryGetResourceDescriptor(Type type) + /// + /// Attempts to get a descriptor for the specified resource type. + /// + public ResourceDescriptor? ResolveResourceDescriptor(Type? type) + { + if (type != null && type.IsOrImplementsInterface()) { - if (type.IsOrImplementsInterface(typeof(IIdentifiable))) - { - Type idType = TryGetIdType(type); + Type? idType = LookupIdType(type); - if (idType != null) - { - return new ResourceDescriptor(type, idType); - } + if (idType != null) + { + return new ResourceDescriptor(type, idType); } + } + + return null; + } - return null; + /// + /// Gets the implementation type with service interface (to be registered in the IoC container) for the specified unbound generic interface and its type + /// arguments, by scanning for types in the specified assembly that match the signature. + /// + /// + /// The assembly to search for matching types. + /// + /// + /// The unbound generic interface to match against. + /// + /// + /// Generic type arguments to construct . + /// + /// + /// ), typeof(Article), typeof(Guid)); + /// ]]> + /// + public (Type implementationType, Type serviceInterface)? GetContainerRegistrationFromAssembly(Assembly assembly, Type unboundInterface, + params Type[] interfaceTypeArguments) + { + ArgumentNullException.ThrowIfNull(assembly); + ArgumentNullException.ThrowIfNull(unboundInterface); + ArgumentNullException.ThrowIfNull(interfaceTypeArguments); + + if (!unboundInterface.IsInterface || !unboundInterface.IsGenericType || unboundInterface != unboundInterface.GetGenericTypeDefinition()) + { + throw new ArgumentException($"Specified type '{unboundInterface.FullName}' is not an unbound generic interface.", nameof(unboundInterface)); } - /// - /// Gets all implementations of a generic interface. - /// - /// - /// The assembly to search in. - /// - /// - /// The open generic interface. - /// - /// - /// Generic type parameters to construct the generic interface. - /// - /// - /// ), typeof(Article), typeof(Guid)); - /// ]]> - /// - public (Type implementation, Type registrationInterface)? GetGenericInterfaceImplementation(Assembly assembly, Type openGenericInterface, - params Type[] interfaceGenericTypeArguments) + if (interfaceTypeArguments.Length != unboundInterface.GetGenericArguments().Length) { - ArgumentGuard.NotNull(assembly, nameof(assembly)); - ArgumentGuard.NotNull(openGenericInterface, nameof(openGenericInterface)); - ArgumentGuard.NotNull(interfaceGenericTypeArguments, nameof(interfaceGenericTypeArguments)); + throw new ArgumentException( + $"Interface '{unboundInterface.FullName}' requires {unboundInterface.GetGenericArguments().Length} type arguments " + + $"instead of {interfaceTypeArguments.Length}.", nameof(interfaceTypeArguments)); + } - if (!openGenericInterface.IsInterface || !openGenericInterface.IsGenericType || - openGenericInterface != openGenericInterface.GetGenericTypeDefinition()) - { - throw new ArgumentException($"Specified type '{openGenericInterface.FullName}' " + "is not an open generic interface.", - nameof(openGenericInterface)); - } + // @formatter:wrap_chained_method_calls chop_always + // @formatter:wrap_before_first_method_call true - if (interfaceGenericTypeArguments.Length != openGenericInterface.GetGenericArguments().Length) - { - throw new ArgumentException( - $"Interface '{openGenericInterface.FullName}' " + $"requires {openGenericInterface.GetGenericArguments().Length} type parameters " + - $"instead of {interfaceGenericTypeArguments.Length}.", nameof(interfaceGenericTypeArguments)); - } + return assembly + .GetTypes() + .Select(type => GetContainerRegistrationFromType(type, unboundInterface, interfaceTypeArguments)) + .FirstOrDefault(result => result != null); - return assembly.GetTypes().Select(type => FindGenericInterfaceImplementationForType(type, openGenericInterface, interfaceGenericTypeArguments)) - .FirstOrDefault(result => result != null); - } + // @formatter:wrap_before_first_method_call restore + // @formatter:wrap_chained_method_calls restore + } - private static (Type implementation, Type registrationInterface)? FindGenericInterfaceImplementationForType(Type nextType, Type openGenericInterface, - Type[] interfaceGenericTypeArguments) + private static (Type implementationType, Type serviceInterface)? GetContainerRegistrationFromType(Type nextType, Type unboundInterface, + Type[] interfaceTypeArguments) + { + if (nextType is { IsNested: false, IsAbstract: false, IsInterface: false }) { - if (!nextType.IsNested) + foreach (Type nextConstructedInterface in nextType.GetInterfaces().Where(type => type.IsGenericType)) { - foreach (Type nextGenericInterface in nextType.GetInterfaces().Where(type => type.IsGenericType)) + Type nextUnboundInterface = nextConstructedInterface.GetGenericTypeDefinition(); + + if (nextUnboundInterface == unboundInterface) { - Type nextOpenGenericInterface = nextGenericInterface.GetGenericTypeDefinition(); + Type[] nextTypeArguments = nextConstructedInterface.GetGenericArguments(); - if (nextOpenGenericInterface == openGenericInterface) + if (nextTypeArguments.Length == interfaceTypeArguments.Length && nextTypeArguments.SequenceEqual(interfaceTypeArguments)) { - Type[] nextGenericArguments = nextGenericInterface.GetGenericArguments(); - - if (nextGenericArguments.Length == interfaceGenericTypeArguments.Length && - nextGenericArguments.SequenceEqual(interfaceGenericTypeArguments)) - { - return (nextType, nextOpenGenericInterface.MakeGenericType(interfaceGenericTypeArguments)); - } + return (nextType, nextUnboundInterface.MakeGenericType(interfaceTypeArguments)); } } } - - return null; } - /// - /// Gets all derivatives of the concrete, generic type. - /// - /// - /// The assembly to search. - /// - /// - /// The open generic type, e.g. `typeof(ResourceDefinition<>)`. - /// - /// - /// Parameters to the generic type. - /// - /// - /// ), typeof(Article)) - /// ]]> - /// - public IReadOnlyCollection GetDerivedGenericTypes(Assembly assembly, Type openGenericType, params Type[] genericArguments) - { - Type genericType = openGenericType.MakeGenericType(genericArguments); - return GetDerivedTypes(assembly, genericType).ToArray(); - } + return null; + } + + /// + /// Gets all derivatives of the specified type. + /// + /// + /// The assembly to search. + /// + /// + /// The inherited type. + /// + /// + /// + /// + public IEnumerable GetDerivedTypes(Assembly assembly, Type baseType) + { + ArgumentNullException.ThrowIfNull(assembly); + ArgumentNullException.ThrowIfNull(baseType); - /// - /// Gets all derivatives of the specified type. - /// - /// - /// The assembly to search. - /// - /// - /// The inherited type. - /// - /// - /// - /// GetDerivedGenericTypes(assembly, typeof(DbContext)) - /// - /// - public IEnumerable GetDerivedTypes(Assembly assembly, Type inheritedType) + foreach (Type type in assembly.GetTypes()) { - foreach (Type type in assembly.GetTypes()) + if (baseType.IsAssignableFrom(type)) { - if (inheritedType.IsAssignableFrom(type)) - { - yield return type; - } + yield return type; } } } diff --git a/src/JsonApiDotNetCore/Controllers/Annotations/DisableQueryStringAttribute.cs b/src/JsonApiDotNetCore/Controllers/Annotations/DisableQueryStringAttribute.cs index 4bf10ec976..d90d67118e 100644 --- a/src/JsonApiDotNetCore/Controllers/Annotations/DisableQueryStringAttribute.cs +++ b/src/JsonApiDotNetCore/Controllers/Annotations/DisableQueryStringAttribute.cs @@ -1,62 +1,59 @@ -using System; -using System.Collections.Generic; -using System.Linq; using JetBrains.Annotations; using JsonApiDotNetCore.QueryStrings; -namespace JsonApiDotNetCore.Controllers.Annotations +namespace JsonApiDotNetCore.Controllers.Annotations; + +/// +/// Used on an ASP.NET controller class to indicate which query string parameters are blocked. +/// +/// { } +/// ]]> +/// { } +/// ]]> +[PublicAPI] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +public sealed class DisableQueryStringAttribute : Attribute { + public static readonly DisableQueryStringAttribute Empty = new(JsonApiQueryStringParameters.None); + + public IReadOnlySet ParameterNames { get; } + /// - /// Used on an ASP.NET Core controller class to indicate which query string parameters are blocked. + /// Disables one or more of the builtin query parameters for a controller. /// - /// { } - /// ]]> - /// { } - /// ]]> - [PublicAPI] - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface)] - public sealed class DisableQueryStringAttribute : Attribute + public DisableQueryStringAttribute(JsonApiQueryStringParameters parameters) { - public static readonly DisableQueryStringAttribute Empty = new DisableQueryStringAttribute(StandardQueryStringParameters.None); - public IReadOnlyCollection ParameterNames { get; } + HashSet parameterNames = []; - /// - /// Disables one or more of the builtin query parameters for a controller. - /// - public DisableQueryStringAttribute(StandardQueryStringParameters parameters) + foreach (JsonApiQueryStringParameters value in Enum.GetValues()) { - var parameterNames = new List(); - - foreach (StandardQueryStringParameters value in Enum.GetValues(typeof(StandardQueryStringParameters))) + if (value != JsonApiQueryStringParameters.None && value != JsonApiQueryStringParameters.All && parameters.HasFlag(value)) { - if (value != StandardQueryStringParameters.None && value != StandardQueryStringParameters.All && parameters.HasFlag(value)) - { - parameterNames.Add(value.ToString()); - } + parameterNames.Add(value.ToString()); } - - ParameterNames = parameterNames; } - /// - /// It is allowed to use a comma-separated list of strings to indicate which query parameters should be disabled, because the user may have defined - /// custom query parameters that are not included in the enum. - /// - public DisableQueryStringAttribute(string parameterNames) - { - ArgumentGuard.NotNullNorEmpty(parameterNames, nameof(parameterNames)); + ParameterNames = parameterNames.AsReadOnly(); + } - ParameterNames = parameterNames.Split(",").ToList(); - } + /// + /// It is allowed to use a comma-separated list of strings to indicate which query parameters should be disabled, because the user may have defined + /// custom query parameters that are not included in the enum. + /// + public DisableQueryStringAttribute(string parameterNames) + { + ArgumentException.ThrowIfNullOrEmpty(parameterNames); - public bool ContainsParameter(StandardQueryStringParameters parameter) - { - string name = parameter.ToString(); - return ParameterNames.Contains(name); - } + ParameterNames = parameterNames.Split(",").ToHashSet().AsReadOnly(); + } + + public bool ContainsParameter(JsonApiQueryStringParameters parameter) + { + string name = parameter.ToString(); + return ParameterNames.Contains(name); } } diff --git a/src/JsonApiDotNetCore/Controllers/Annotations/DisableRoutingConventionAttribute.cs b/src/JsonApiDotNetCore/Controllers/Annotations/DisableRoutingConventionAttribute.cs index 5cd68b2e6f..19df79dc2b 100644 --- a/src/JsonApiDotNetCore/Controllers/Annotations/DisableRoutingConventionAttribute.cs +++ b/src/JsonApiDotNetCore/Controllers/Annotations/DisableRoutingConventionAttribute.cs @@ -1,18 +1,14 @@ -using System; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Controllers.Annotations -{ - /// - /// Used on an ASP.NET Core controller class to indicate that a custom route is used instead of the built-in routing convention. - /// - /// { } - /// ]]> - [PublicAPI] - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface)] - public sealed class DisableRoutingConventionAttribute : Attribute - { - } -} +namespace JsonApiDotNetCore.Controllers.Annotations; + +/// +/// Used on an ASP.NET controller class to indicate that a custom route is used instead of the built-in routing convention. +/// +/// { } +/// ]]> +[PublicAPI] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)] +public sealed class DisableRoutingConventionAttribute : Attribute; diff --git a/src/JsonApiDotNetCore/Controllers/Annotations/HttpReadOnlyAttribute.cs b/src/JsonApiDotNetCore/Controllers/Annotations/HttpReadOnlyAttribute.cs deleted file mode 100644 index b4c0fb7675..0000000000 --- a/src/JsonApiDotNetCore/Controllers/Annotations/HttpReadOnlyAttribute.cs +++ /dev/null @@ -1,24 +0,0 @@ -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Controllers.Annotations -{ - /// - /// Used on an ASP.NET Core controller class to indicate write actions must be blocked. - /// - /// - /// { - /// } - /// ]]> - [PublicAPI] - public sealed class HttpReadOnlyAttribute : HttpRestrictAttribute - { - protected override string[] Methods { get; } = - { - "POST", - "PATCH", - "DELETE" - }; - } -} diff --git a/src/JsonApiDotNetCore/Controllers/Annotations/HttpRestrictAttribute.cs b/src/JsonApiDotNetCore/Controllers/Annotations/HttpRestrictAttribute.cs deleted file mode 100644 index c2534471f9..0000000000 --- a/src/JsonApiDotNetCore/Controllers/Annotations/HttpRestrictAttribute.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Linq; -using System.Net.Http; -using System.Threading.Tasks; -using JsonApiDotNetCore.Errors; -using Microsoft.AspNetCore.Mvc.Filters; - -namespace JsonApiDotNetCore.Controllers.Annotations -{ - public abstract class HttpRestrictAttribute : ActionFilterAttribute - { - protected abstract string[] Methods { get; } - - public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) - { - ArgumentGuard.NotNull(context, nameof(context)); - ArgumentGuard.NotNull(next, nameof(next)); - - string method = context.HttpContext.Request.Method; - - if (!CanExecuteAction(method)) - { - throw new RequestMethodNotAllowedException(new HttpMethod(method)); - } - - await next(); - } - - private bool CanExecuteAction(string requestMethod) - { - return !Methods.Contains(requestMethod); - } - } -} diff --git a/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpDeleteAttribute.cs b/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpDeleteAttribute.cs deleted file mode 100644 index 93733d6885..0000000000 --- a/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpDeleteAttribute.cs +++ /dev/null @@ -1,22 +0,0 @@ -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Controllers.Annotations -{ - /// - /// Used on an ASP.NET Core controller class to indicate the DELETE verb must be blocked. - /// - /// - /// { - /// } - /// ]]> - [PublicAPI] - public sealed class NoHttpDeleteAttribute : HttpRestrictAttribute - { - protected override string[] Methods { get; } = - { - "DELETE" - }; - } -} diff --git a/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpPatchAttribute.cs b/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpPatchAttribute.cs deleted file mode 100644 index 29a84b386a..0000000000 --- a/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpPatchAttribute.cs +++ /dev/null @@ -1,22 +0,0 @@ -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Controllers.Annotations -{ - /// - /// Used on an ASP.NET Core controller class to indicate the PATCH verb must be blocked. - /// - /// - /// { - /// } - /// ]]> - [PublicAPI] - public sealed class NoHttpPatchAttribute : HttpRestrictAttribute - { - protected override string[] Methods { get; } = - { - "PATCH" - }; - } -} diff --git a/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpPostAttribute.cs b/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpPostAttribute.cs deleted file mode 100644 index 1d47890739..0000000000 --- a/src/JsonApiDotNetCore/Controllers/Annotations/NoHttpPostAttribute.cs +++ /dev/null @@ -1,22 +0,0 @@ -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Controllers.Annotations -{ - /// - /// Used on an ASP.NET Core controller class to indicate the POST verb must be blocked. - /// - /// - /// { - /// } - /// ]]> - [PublicAPI] - public sealed class NoHttpPostAttribute : HttpRestrictAttribute - { - protected override string[] Methods { get; } = - { - "POST" - }; - } -} diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs index 978fe828f5..acd6528500 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiController.cs @@ -1,403 +1,410 @@ -using System.Collections.Generic; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; +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; -namespace JsonApiDotNetCore.Controllers +namespace JsonApiDotNetCore.Controllers; + +/// +/// Implements the foundational ASP.NET controller layer in the JsonApiDotNetCore architecture that delegates to a Resource Service. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +public abstract class BaseJsonApiController : CoreJsonApiController + where TResource : class, IIdentifiable { + private readonly IJsonApiOptions _options; + private readonly IResourceGraph _resourceGraph; + private readonly IGetAllService? _getAll; + private readonly IGetByIdService? _getById; + private readonly IGetSecondaryService? _getSecondary; + private readonly IGetRelationshipService? _getRelationship; + private readonly ICreateService? _create; + private readonly IAddToRelationshipService? _addToRelationship; + private readonly IUpdateService? _update; + private readonly ISetRelationshipService? _setRelationship; + private readonly IDeleteService? _delete; + private readonly IRemoveFromRelationshipService? _removeFromRelationship; + private readonly TraceLogWriter> _traceWriter; + /// - /// Implements the foundational ASP.NET Core controller layer in the JsonApiDotNetCore architecture that delegates to a Resource Service. + /// Creates an instance from a read/write service. /// - /// - /// The resource type. - /// - /// - /// The resource identifier type. - /// - public abstract class BaseJsonApiController : CoreJsonApiController - where TResource : class, IIdentifiable + protected BaseJsonApiController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : this(options, resourceGraph, loggerFactory, resourceService, resourceService) { - private readonly IJsonApiOptions _options; - private readonly IGetAllService _getAll; - private readonly IGetByIdService _getById; - private readonly IGetSecondaryService _getSecondary; - private readonly IGetRelationshipService _getRelationship; - private readonly ICreateService _create; - private readonly IAddToRelationshipService _addToRelationship; - private readonly IUpdateService _update; - private readonly ISetRelationshipService _setRelationship; - private readonly IDeleteService _delete; - private readonly IRemoveFromRelationshipService _removeFromRelationship; - private readonly TraceLogWriter> _traceWriter; - - /// - /// Creates an instance from a read/write service. - /// - protected BaseJsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : this(options, loggerFactory, resourceService, resourceService) - { - } + } - /// - /// Creates an instance from separate services for reading and writing. - /// - protected BaseJsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceQueryService queryService = null, - IResourceCommandService commandService = null) - : this(options, loggerFactory, queryService, queryService, queryService, queryService, commandService, commandService, commandService, - commandService, commandService, commandService) - { - } + /// + /// Creates an instance from separate services for reading and writing. + /// + protected BaseJsonApiController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceQueryService? queryService = null, IResourceCommandService? commandService = null) + : this(options, resourceGraph, loggerFactory, queryService, queryService, queryService, queryService, commandService, commandService, commandService, + commandService, commandService, commandService) + { + } + + /// + /// Creates an instance from separate services for the various individual read and write methods. + /// + protected BaseJsonApiController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IGetAllService? getAll = null, IGetByIdService? getById = null, + IGetSecondaryService? getSecondary = null, IGetRelationshipService? getRelationship = null, + ICreateService? create = null, IAddToRelationshipService? addToRelationship = null, + IUpdateService? update = null, ISetRelationshipService? setRelationship = null, + IDeleteService? delete = null, IRemoveFromRelationshipService? removeFromRelationship = null) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(resourceGraph); + ArgumentNullException.ThrowIfNull(loggerFactory); + + _options = options; + _resourceGraph = resourceGraph; + _traceWriter = new TraceLogWriter>(loggerFactory); + _getAll = getAll; + _getById = getById; + _getSecondary = getSecondary; + _getRelationship = getRelationship; + _create = create; + _addToRelationship = addToRelationship; + _update = update; + _setRelationship = setRelationship; + _delete = delete; + _removeFromRelationship = removeFromRelationship; + } + + /// + /// Gets a collection of primary resources. Example: + /// + public virtual async Task GetAsync(CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(); - /// - /// Creates an instance from separate services for the various individual read and write methods. - /// - protected BaseJsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IGetAllService getAll = null, - IGetByIdService getById = null, IGetSecondaryService getSecondary = null, - IGetRelationshipService getRelationship = null, ICreateService create = null, - IAddToRelationshipService addToRelationship = null, IUpdateService update = null, - ISetRelationshipService setRelationship = null, IDeleteService delete = null, - IRemoveFromRelationshipService removeFromRelationship = null) + if (_getAll == null) { - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); - - _options = options; - _traceWriter = new TraceLogWriter>(loggerFactory); - _getAll = getAll; - _getById = getById; - _getSecondary = getSecondary; - _getRelationship = getRelationship; - _create = create; - _addToRelationship = addToRelationship; - _update = update; - _setRelationship = setRelationship; - _delete = delete; - _removeFromRelationship = removeFromRelationship; + throw new RouteNotAvailableException(HttpMethod.Get, Request.Path); } - /// - /// Gets a collection of top-level (non-nested) resources. Example: GET /articles HTTP/1.1 - /// - public virtual async Task GetAsync(CancellationToken cancellationToken) - { - _traceWriter.LogMethodStart(); + IReadOnlyCollection resources = await _getAll.GetAsync(cancellationToken); - if (_getAll == null) - { - throw new RequestMethodNotAllowedException(HttpMethod.Get); - } + return Ok(resources); + } - IReadOnlyCollection resources = await _getAll.GetAsync(cancellationToken); + /// + /// Gets a single primary resource by ID. Example: + /// + public virtual async Task GetAsync([DisallowNull] TId id, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + id + }); - return Ok(resources); + if (_getById == null) + { + throw new RouteNotAvailableException(HttpMethod.Get, Request.Path); } - /// - /// Gets a single top-level (non-nested) resource by ID. Example: /articles/1 - /// - public virtual async Task GetAsync(TId id, CancellationToken cancellationToken) - { - _traceWriter.LogMethodStart(new - { - id - }); + TResource resource = await _getById.GetAsync(id, cancellationToken); - if (_getById == null) - { - throw new RequestMethodNotAllowedException(HttpMethod.Get); - } + return Ok(resource); + } + + /// + /// Gets a secondary resource or collection of secondary resources. Example: Example: + /// + /// + public virtual async Task GetSecondaryAsync([DisallowNull] TId id, [PreserveEmptyString] string relationshipName, + CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + id, + relationshipName + }); - TResource resource = await _getById.GetAsync(id, cancellationToken); + ArgumentNullException.ThrowIfNull(relationshipName); - return Ok(resource); + if (_getSecondary == null) + { + throw new RouteNotAvailableException(HttpMethod.Get, Request.Path); } - /// - /// Gets a single resource or multiple resources at a nested endpoint. Examples: GET /articles/1/author HTTP/1.1 GET /articles/1/revisions HTTP/1.1 - /// - public virtual async Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) - { - _traceWriter.LogMethodStart(new - { - id, - relationshipName - }); + object? rightValue = await _getSecondary.GetSecondaryAsync(id, relationshipName, cancellationToken); - ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); + return Ok(rightValue); + } - if (_getSecondary == null) - { - throw new RequestMethodNotAllowedException(HttpMethod.Get); - } + /// + /// Gets a relationship value, which can be a null, a single object or a collection. Example: + /// Example: + /// + /// + public virtual async Task GetRelationshipAsync([DisallowNull] TId id, [PreserveEmptyString] string relationshipName, + CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + id, + relationshipName + }); - object relationship = await _getSecondary.GetSecondaryAsync(id, relationshipName, cancellationToken); + ArgumentNullException.ThrowIfNull(relationshipName); - return Ok(relationship); + if (_getRelationship == null) + { + throw new RouteNotAvailableException(HttpMethod.Get, Request.Path); } - /// - /// Gets a single resource relationship. Example: GET /articles/1/relationships/author HTTP/1.1 Example: GET /articles/1/relationships/revisions HTTP/1.1 - /// - public virtual async Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) - { - _traceWriter.LogMethodStart(new - { - id, - relationshipName - }); + object? rightValue = await _getRelationship.GetRelationshipAsync(id, relationshipName, cancellationToken); - ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); + return Ok(rightValue); + } - if (_getRelationship == null) - { - throw new RequestMethodNotAllowedException(HttpMethod.Get); - } + /// + /// Creates a new resource with attributes, relationships or both. Example: + /// + public virtual async Task PostAsync([FromBody] TResource resource, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + resource + }); - object rightResources = await _getRelationship.GetRelationshipAsync(id, relationshipName, cancellationToken); + ArgumentNullException.ThrowIfNull(resource); - return Ok(rightResources); + if (_create == null) + { + throw new RouteNotAvailableException(HttpMethod.Post, Request.Path); } - /// - /// Creates a new resource with attributes, relationships or both. Example: POST /articles HTTP/1.1 - /// - public virtual async Task PostAsync([FromBody] TResource resource, CancellationToken cancellationToken) + if (_options.ValidateModelState && !ModelState.IsValid) { - _traceWriter.LogMethodStart(new - { - resource - }); + throw new InvalidModelStateException(ModelState, typeof(TResource), _options.IncludeExceptionStackTraceInErrors, _resourceGraph); + } - ArgumentGuard.NotNull(resource, nameof(resource)); + TResource? newResource = await _create.CreateAsync(resource, cancellationToken); - if (_create == null) - { - throw new RequestMethodNotAllowedException(HttpMethod.Post); - } + string resourceId = (newResource ?? resource).StringId!; + string locationUrl = GetLocationUrl(resourceId); - if (!_options.AllowClientGeneratedIds && resource.StringId != null) - { - throw new ResourceIdInCreateResourceNotAllowedException(); - } + if (newResource == null) + { + HttpContext.Response.Headers.Location = locationUrl; + return NoContent(); + } - if (_options.ValidateModelState && !ModelState.IsValid) - { - throw new InvalidModelStateException(ModelState, typeof(TResource), _options.IncludeExceptionStackTraceInErrors, - _options.SerializerNamingStrategy); - } + return Created(locationUrl, newResource); + } - TResource newResource = await _create.CreateAsync(resource, cancellationToken); + private string GetLocationUrl(string resourceId) + { + PathString locationPath = HttpContext.Request.Path.Add($"/{resourceId}"); - string resourceId = (newResource ?? resource).StringId; - string locationUrl = $"{HttpContext.Request.Path}/{resourceId}"; + return _options.UseRelativeLinks + ? UriHelper.BuildRelative(HttpContext.Request.PathBase, locationPath) + : UriHelper.BuildAbsolute(HttpContext.Request.Scheme, HttpContext.Request.Host, HttpContext.Request.PathBase, locationPath); + } - if (newResource == null) - { - HttpContext.Response.Headers["Location"] = locationUrl; - return NoContent(); - } + /// + /// Adds resources to a to-many relationship. Example: + /// + /// + /// Identifies the left side of the relationship. + /// + /// + /// The relationship to add resources to. + /// + /// + /// The set of resources to add to the relationship. + /// + /// + /// Propagates notification that request handling should be canceled. + /// + public virtual async Task PostRelationshipAsync([DisallowNull] TId id, [PreserveEmptyString] string relationshipName, + [FromBody] ISet rightResourceIds, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + id, + relationshipName, + rightResourceIds + }); - return Created(locationUrl, newResource); - } + ArgumentNullException.ThrowIfNull(relationshipName); + ArgumentNullException.ThrowIfNull(rightResourceIds); - /// - /// Adds resources to a to-many relationship. Example: POST /articles/1/revisions HTTP/1.1 - /// - /// - /// The identifier of the primary resource. - /// - /// - /// The relationship to add resources to. - /// - /// - /// The set of resources to add to the relationship. - /// - /// - /// Propagates notification that request handling should be canceled. - /// - public virtual async Task PostRelationshipAsync(TId id, string relationshipName, [FromBody] ISet secondaryResourceIds, - CancellationToken cancellationToken) + if (_addToRelationship == null) { - _traceWriter.LogMethodStart(new - { - id, - relationshipName, - secondaryResourceIds - }); + throw new RouteNotAvailableException(HttpMethod.Post, Request.Path); + } - ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); - ArgumentGuard.NotNull(secondaryResourceIds, nameof(secondaryResourceIds)); + await _addToRelationship.AddToToManyRelationshipAsync(id, relationshipName, rightResourceIds, cancellationToken); - if (_addToRelationship == null) - { - throw new RequestMethodNotAllowedException(HttpMethod.Post); - } + return NoContent(); + } - await _addToRelationship.AddToToManyRelationshipAsync(id, relationshipName, secondaryResourceIds, cancellationToken); + /// + /// Updates 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. Example: + /// + public virtual async Task PatchAsync([DisallowNull] TId id, [FromBody] TResource resource, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + id, + resource + }); - return NoContent(); - } + ArgumentNullException.ThrowIfNull(resource); - /// - /// Updates 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. Example: PATCH /articles/1 HTTP/1.1 - /// - public virtual async Task PatchAsync(TId id, [FromBody] TResource resource, CancellationToken cancellationToken) + if (_update == null) { - _traceWriter.LogMethodStart(new - { - id, - resource - }); - - ArgumentGuard.NotNull(resource, nameof(resource)); - - if (_update == null) - { - throw new RequestMethodNotAllowedException(HttpMethod.Patch); - } - - if (_options.ValidateModelState && !ModelState.IsValid) - { - throw new InvalidModelStateException(ModelState, typeof(TResource), _options.IncludeExceptionStackTraceInErrors, - _options.SerializerNamingStrategy); - } - - TResource updated = await _update.UpdateAsync(id, resource, cancellationToken); - return updated == null ? (IActionResult)NoContent() : Ok(updated); + throw new RouteNotAvailableException(HttpMethod.Patch, Request.Path); } - /// - /// Performs a complete replacement of a relationship on an existing resource. Example: PATCH /articles/1/relationships/author HTTP/1.1 Example: PATCH - /// /articles/1/relationships/revisions HTTP/1.1 - /// - /// - /// The identifier of the primary resource. - /// - /// - /// The relationship for which to perform a complete replacement. - /// - /// - /// The resource or set of resources to assign to the relationship. - /// - /// - /// Propagates notification that request handling should be canceled. - /// - public virtual async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object secondaryResourceIds, - CancellationToken cancellationToken) + if (_options.ValidateModelState && !ModelState.IsValid) { - _traceWriter.LogMethodStart(new - { - id, - relationshipName, - secondaryResourceIds - }); + throw new InvalidModelStateException(ModelState, typeof(TResource), _options.IncludeExceptionStackTraceInErrors, _resourceGraph); + } - ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); + TResource? updated = await _update.UpdateAsync(id, resource, cancellationToken); - if (_setRelationship == null) - { - throw new RequestMethodNotAllowedException(HttpMethod.Patch); - } + return updated == null ? NoContent() : Ok(updated); + } - await _setRelationship.SetRelationshipAsync(id, relationshipName, secondaryResourceIds, cancellationToken); + /// + /// Performs a complete replacement of a relationship on an existing resource. Example: + /// Example: + /// + /// + /// + /// Identifies the left side of the relationship. + /// + /// + /// The relationship for which to perform a complete replacement. + /// + /// + /// The resource or set of resources to assign to the relationship. + /// + /// + /// Propagates notification that request handling should be canceled. + /// + public virtual async Task PatchRelationshipAsync([DisallowNull] TId id, [PreserveEmptyString] string relationshipName, + [FromBody] object? rightValue, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + id, + relationshipName, + rightValue + }); - return NoContent(); - } + ArgumentNullException.ThrowIfNull(relationshipName); - /// - /// Deletes an existing resource. Example: DELETE /articles/1 HTTP/1.1 - /// - public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToken) + if (_setRelationship == null) { - _traceWriter.LogMethodStart(new - { - id - }); - - if (_delete == null) - { - throw new RequestMethodNotAllowedException(HttpMethod.Delete); - } + throw new RouteNotAvailableException(HttpMethod.Patch, Request.Path); + } - await _delete.DeleteAsync(id, cancellationToken); + await _setRelationship.SetRelationshipAsync(id, relationshipName, rightValue, cancellationToken); - return NoContent(); - } + return NoContent(); + } - /// - /// Removes resources from a to-many relationship. Example: DELETE /articles/1/relationships/revisions HTTP/1.1 - /// - /// - /// The identifier of the primary resource. - /// - /// - /// The relationship to remove resources from. - /// - /// - /// The set of resources to remove from the relationship. - /// - /// - /// Propagates notification that request handling should be canceled. - /// - public virtual async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] ISet secondaryResourceIds, - CancellationToken cancellationToken) + /// + /// Deletes an existing resource. Example: + /// + public virtual async Task DeleteAsync([DisallowNull] TId id, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - _traceWriter.LogMethodStart(new - { - id, - relationshipName, - secondaryResourceIds - }); + id + }); - ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); - ArgumentGuard.NotNull(secondaryResourceIds, nameof(secondaryResourceIds)); - - if (_removeFromRelationship == null) - { - throw new RequestMethodNotAllowedException(HttpMethod.Delete); - } + if (_delete == null) + { + throw new RouteNotAvailableException(HttpMethod.Delete, Request.Path); + } - await _removeFromRelationship.RemoveFromToManyRelationshipAsync(id, relationshipName, secondaryResourceIds, cancellationToken); + await _delete.DeleteAsync(id, cancellationToken); - return NoContent(); - } + return NoContent(); } - /// - public abstract class BaseJsonApiController : BaseJsonApiController - where TResource : class, IIdentifiable + /// + /// Removes resources from a to-many relationship. Example: + /// + /// + /// Identifies the left side of the relationship. + /// + /// + /// The relationship to remove resources from. + /// + /// + /// The set of resources to remove from the relationship. + /// + /// + /// Propagates notification that request handling should be canceled. + /// + public virtual async Task DeleteRelationshipAsync([DisallowNull] TId id, [PreserveEmptyString] string relationshipName, + [FromBody] ISet rightResourceIds, CancellationToken cancellationToken) { - /// - protected BaseJsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService, resourceService) + _traceWriter.LogMethodStart(new { - } + id, + relationshipName, + rightResourceIds + }); - /// - protected BaseJsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceQueryService queryService = null, - IResourceCommandService commandService = null) - : base(options, loggerFactory, queryService, commandService) - { - } + ArgumentNullException.ThrowIfNull(relationshipName); + ArgumentNullException.ThrowIfNull(rightResourceIds); - /// - protected BaseJsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IGetAllService getAll = null, - IGetByIdService getById = null, IGetSecondaryService getSecondary = null, - IGetRelationshipService getRelationship = null, ICreateService create = null, - IAddToRelationshipService addToRelationship = null, IUpdateService update = null, - ISetRelationshipService setRelationship = null, IDeleteService delete = null, - IRemoveFromRelationshipService removeFromRelationship = null) - : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, addToRelationship, update, setRelationship, delete, - removeFromRelationship) + if (_removeFromRelationship == null) { + throw new RouteNotAvailableException(HttpMethod.Delete, Request.Path); } + + await _removeFromRelationship.RemoveFromToManyRelationshipAsync(id, relationshipName, rightResourceIds, cancellationToken); + + return NoContent(); } } diff --git a/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs index 5da8b2b4f5..6c6703c132 100644 --- a/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/BaseJsonApiOperationsController.cs @@ -1,201 +1,279 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; +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; -namespace JsonApiDotNetCore.Controllers +namespace JsonApiDotNetCore.Controllers; + +/// +/// Implements the foundational ASP.NET controller layer in the JsonApiDotNetCore architecture for handling atomic:operations requests. See +/// https://jsonapi.org/ext/atomic/ for details. Delegates work to . +/// +[PublicAPI] +public abstract class BaseJsonApiOperationsController : CoreJsonApiController { + private readonly IJsonApiOptions _options; + private readonly IResourceGraph _resourceGraph; + 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, IAtomicOperationFilter operationFilter) + { + 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); + } + /// - /// Implements the foundational ASP.NET Core controller layer in the JsonApiDotNetCore architecture for handling atomic:operations requests. See - /// https://jsonapi.org/ext/atomic/ for details. Delegates work to . + /// Atomically processes a list of operations and returns a list of results. All changes are reverted if processing fails. If processing succeeds but + /// none of the operations returns any data, then HTTP 201 is returned instead of 200. /// - [PublicAPI] - public abstract class BaseJsonApiOperationsController : CoreJsonApiController + /// + /// The next example creates a new resource. + /// + /// + /// + /// The next example updates an existing resource. + /// + /// + /// + /// The next example deletes an existing resource. + /// + /// + public virtual async Task PostOperationsAsync([FromBody] IList operations, CancellationToken cancellationToken) { - private readonly IJsonApiOptions _options; - private readonly IOperationsProcessor _processor; - private readonly IJsonApiRequest _request; - private readonly ITargetedFields _targetedFields; - private readonly TraceLogWriter _traceWriter; - - protected BaseJsonApiOperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IOperationsProcessor processor, - IJsonApiRequest request, ITargetedFields targetedFields) + _traceWriter.LogMethodStart(new { - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); - ArgumentGuard.NotNull(processor, nameof(processor)); - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); - - _options = options; - _processor = processor; - _request = request; - _targetedFields = targetedFields; - _traceWriter = new TraceLogWriter(loggerFactory); - } + operations + }); - /// - /// Atomically processes a list of operations and returns a list of results. All changes are reverted if processing fails. If processing succeeds but - /// none of the operations returns any data, then HTTP 201 is returned instead of 200. - /// - /// - /// The next example creates a new resource. - /// - /// - /// - /// The next example updates an existing resource. - /// - /// - /// - /// The next example deletes an existing resource. - /// - /// - public virtual async Task PostOperationsAsync([FromBody] IList operations, CancellationToken cancellationToken) - { - _traceWriter.LogMethodStart(new - { - operations - }); + ArgumentNullException.ThrowIfNull(operations); - ArgumentGuard.NotNull(operations, nameof(operations)); + ValidateEnabledOperations(operations); - ValidateClientGeneratedIds(operations); + if (_options.ValidateModelState) + { + ValidateModelState(operations); + } - if (_options.ValidateModelState) - { - ValidateModelState(operations); - } + IList results = await _processor.ProcessAsync(operations, cancellationToken); + return results.Any(result => result != null) ? Ok(results) : NoContent(); + } - IList results = await _processor.ProcessAsync(operations, cancellationToken); - return results.Any(result => result != null) ? (IActionResult)Ok(results) : NoContent(); - } + protected virtual void ValidateEnabledOperations(IList operations) + { + ArgumentNullException.ThrowIfNull(operations); + + List errors = []; - protected virtual void ValidateClientGeneratedIds(IEnumerable operations) + for (int operationIndex = 0; operationIndex < operations.Count; operationIndex++) { - if (!_options.AllowClientGeneratedIds) + IJsonApiRequest operationRequest = operations[operationIndex].Request; + WriteOperationKind writeOperation = operationRequest.WriteOperation!.Value; + + if (operationRequest.Relationship != null && !_operationFilter.IsEnabled(operationRequest.Relationship.LeftType, writeOperation)) { - int index = 0; + string operationCode = GetOperationCodeText(writeOperation); - foreach (OperationContainer operation in operations) + errors.Add(new ErrorObject(HttpStatusCode.Forbidden) { - if (operation.Kind == OperationKind.CreateResource && operation.Resource.StringId != null) + 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 { - throw new ResourceIdInCreateResourceNotAllowedException(index); + Pointer = $"/atomic:operations[{operationIndex}]" } + }); + } + else if (operationRequest.PrimaryResourceType != null && !_operationFilter.IsEnabled(operationRequest.PrimaryResourceType, writeOperation)) + { + string operationCode = GetOperationCodeText(writeOperation); - index++; - } + 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}]" + } + }); } } - protected virtual void ValidateModelState(IEnumerable operations) + if (errors.Count > 0) { - // 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. - - var violations = new List(); + throw new JsonApiException(errors); + } + } - int index = 0; + 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}'.") + }; - foreach (OperationContainer operation in operations) - { - if (operation.Kind == OperationKind.CreateResource || operation.Kind == OperationKind.UpdateResource) - { - _targetedFields.Attributes = operation.TargetedFields.Attributes; - _targetedFields.Relationships = operation.TargetedFields.Relationships; + return operationCode.ToString().ToLowerInvariant(); + } - _request.CopyFrom(operation.Request); + protected virtual void ValidateModelState(IList operations) + { + ArgumentNullException.ThrowIfNull(operations); - var validationContext = new ActionContext(); - ObjectValidator.Validate(validationContext, null, string.Empty, operation.Resource); + // 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. - if (!validationContext.ModelState.IsValid) - { - AddValidationErrors(validationContext.ModelState, operation.Resource.GetType(), index, violations); - } - } + using IDisposable _ = new RevertRequestStateOnDispose(_request, _targetedFields); - index++; - } + int operationIndex = 0; + List<(string key, ModelStateEntry? entry)> requestModelState = []; + int maxErrorsRemaining = ModelState.MaxAllowedErrors; - if (violations.Any()) + foreach (OperationContainer operation in operations) + { + if (maxErrorsRemaining < 1) { - throw new InvalidModelStateException(violations, _options.IncludeExceptionStackTraceInErrors, _options.SerializerNamingStrategy); + break; } + + maxErrorsRemaining = ValidateOperation(operation, operationIndex, requestModelState, maxErrorsRemaining); + + operationIndex++; } - private static void AddValidationErrors(ModelStateDictionary modelState, Type resourceType, int operationIndex, List violations) + if (requestModelState.Count > 0) { - foreach ((string propertyName, ModelStateEntry entry) in modelState) - { - AddValidationErrors(entry, propertyName, resourceType, operationIndex, violations); - } + Dictionary modelStateDictionary = requestModelState.ToDictionary(tuple => tuple.key, tuple => tuple.entry); + + throw new InvalidModelStateException(modelStateDictionary, typeof(IList), _options.IncludeExceptionStackTraceInErrors, + _resourceGraph, + (collectionType, index) => collectionType == typeof(IList) ? operations[index].Resource.GetClrType() : null); } + } - private static void AddValidationErrors(ModelStateEntry entry, string propertyName, Type resourceType, int operationIndex, - List violations) + private int ValidateOperation(OperationContainer operation, int operationIndex, List<(string key, ModelStateEntry? entry)> requestModelState, + int maxErrorsRemaining) + { + if (operation.Request.WriteOperation is WriteOperationKind.CreateResource or WriteOperationKind.UpdateResource) { - foreach (ModelError error in entry.Errors) + _targetedFields.CopyFrom(operation.TargetedFields); + _request.CopyFrom(operation.Request); + + var validationContext = new ActionContext { - string prefix = $"/atomic:operations[{operationIndex}]/data/attributes/"; - var violation = new ModelStateViolation(prefix, propertyName, resourceType, error); + ModelState = + { + MaxAllowedErrors = maxErrorsRemaining + }, + HttpContext = HttpContext + }; + + ObjectValidator.Validate(validationContext, null, string.Empty, operation.Resource); + + if (!validationContext.ModelState.IsValid) + { + int errorsRemaining = maxErrorsRemaining; + + foreach (string key in validationContext.ModelState.Keys) + { + ModelStateEntry entry = validationContext.ModelState[key]!; - violations.Add(violation); + if (entry.ValidationState == ModelValidationState.Invalid) + { + string operationKey = $"[{operationIndex}].{nameof(OperationContainer.Resource)}.{key}"; + + if (entry.Errors.Count > 0 && entry.Errors[0].Exception is TooManyModelErrorsException) + { + requestModelState.Insert(0, (operationKey, entry)); + } + else + { + requestModelState.Add((operationKey, entry)); + } + + errorsRemaining -= entry.Errors.Count; + } + } + + return errorsRemaining; } } + + return maxErrorsRemaining; } } diff --git a/src/JsonApiDotNetCore/Controllers/CoreJsonApiController.cs b/src/JsonApiDotNetCore/Controllers/CoreJsonApiController.cs index 17db572550..f81ec9d071 100644 --- a/src/JsonApiDotNetCore/Controllers/CoreJsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/CoreJsonApiController.cs @@ -1,31 +1,37 @@ -using System.Collections.Generic; +using System.Collections.ObjectModel; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Mvc; -namespace JsonApiDotNetCore.Controllers +namespace JsonApiDotNetCore.Controllers; + +/// +/// Provides helper methods to raise JSON:API compliant errors from controller actions. +/// +public abstract class CoreJsonApiController : ControllerBase { - /// - /// Provides helper methods to raise JSON:API compliant errors from controller actions. - /// - public abstract class CoreJsonApiController : ControllerBase + protected IActionResult Error(ErrorObject error) { - protected IActionResult Error(Error error) + ArgumentNullException.ThrowIfNull(error); + + return new ObjectResult(error) { - ArgumentGuard.NotNull(error, nameof(error)); + StatusCode = (int)error.StatusCode + }; + } - return Error(error.AsEnumerable()); - } + protected IActionResult Error(IEnumerable errors) + { + ReadOnlyCollection? errorCollection = ToCollection(errors); + ArgumentGuard.NotNullNorEmpty(errorCollection, nameof(errors)); - protected IActionResult Error(IEnumerable errors) + return new ObjectResult(errorCollection) { - ArgumentGuard.NotNull(errors, nameof(errors)); - - var document = new ErrorDocument(errors); + StatusCode = (int)ErrorObject.GetResponseStatusCode(errorCollection) + }; + } - return new ObjectResult(document) - { - StatusCode = (int)document.GetErrorStatusCode() - }; - } + private static ReadOnlyCollection? ToCollection(IEnumerable? errors) + { + return errors?.ToArray().AsReadOnly(); } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs index cfc9399957..cd58e2d18d 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiCommandController.cs @@ -1,90 +1,22 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; -using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCore.Controllers -{ - /// - /// The base class to derive resource-specific write-only 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 BaseJsonApiController directly. - /// - /// - /// The resource type. - /// - /// - /// The resource identifier type. - /// - public abstract class JsonApiCommandController : BaseJsonApiController - where TResource : class, IIdentifiable - { - /// - /// Creates an instance from a write-only service. - /// - protected JsonApiCommandController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceCommandService commandService) - : base(options, loggerFactory, null, commandService) - { - } - - /// - [HttpPost] - public override async Task PostAsync([FromBody] 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 secondaryResourceIds, - CancellationToken cancellationToken) - { - return await base.PostRelationshipAsync(id, relationshipName, secondaryResourceIds, cancellationToken); - } - - /// - [HttpPatch("{id}")] - public override async Task PatchAsync(TId id, [FromBody] 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 secondaryResourceIds, - CancellationToken cancellationToken) - { - return await base.PatchRelationshipAsync(id, relationshipName, secondaryResourceIds, cancellationToken); - } - - /// - [HttpDelete("{id}")] - public override async Task DeleteAsync(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 secondaryResourceIds, - CancellationToken cancellationToken) - { - return await base.DeleteRelationshipAsync(id, relationshipName, secondaryResourceIds, cancellationToken); - } - } - - /// - public abstract class JsonApiCommandController : JsonApiCommandController - where TResource : class, IIdentifiable - { - /// - protected JsonApiCommandController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceCommandService commandService) - : base(options, loggerFactory, commandService) - { - } - } -} +namespace JsonApiDotNetCore.Controllers; + +/// +/// The base class to derive resource-specific write-only controllers from. Returns HTTP 405 on read-only endpoints. If you want to provide routing +/// templates yourself, you should derive from BaseJsonApiController directly. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +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 a88c276a0b..84e4dad3b5 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -1,143 +1,126 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; +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; -namespace JsonApiDotNetCore.Controllers -{ - /// - /// The base class to derive resource-specific 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 BaseJsonApiController directly. - /// - /// - /// The resource type. - /// - /// - /// The resource identifier type. - /// - public abstract class JsonApiController : BaseJsonApiController - where TResource : class, IIdentifiable - { - /// - protected JsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } +#pragma warning disable format - /// - protected JsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IGetAllService getAll = null, - IGetByIdService getById = null, IGetSecondaryService getSecondary = null, - IGetRelationshipService getRelationship = null, ICreateService create = null, - IAddToRelationshipService addToRelationship = null, IUpdateService update = null, - ISetRelationshipService setRelationship = null, IDeleteService delete = null, - IRemoveFromRelationshipService removeFromRelationship = null) - : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, addToRelationship, update, setRelationship, delete, - removeFromRelationship) - { - } +namespace JsonApiDotNetCore.Controllers; - /// - [HttpGet] - [HttpHead] - public override async Task GetAsync(CancellationToken cancellationToken) - { - return await base.GetAsync(cancellationToken); - } +/// +/// The base class to derive resource-specific 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 BaseJsonApiController directly. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +public abstract class JsonApiController : BaseJsonApiController + where TResource : class, IIdentifiable +{ + /// + protected JsonApiController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IResourceService resourceService) + : base(options, resourceGraph, loggerFactory, resourceService) + { + } - /// - [HttpGet("{id}")] - [HttpHead("{id}")] - public override async Task GetAsync(TId id, CancellationToken cancellationToken) - { - return await base.GetAsync(id, cancellationToken); - } + /// + protected JsonApiController(IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, + IGetAllService? getAll = null, IGetByIdService? getById = null, + IGetSecondaryService? getSecondary = null, IGetRelationshipService? getRelationship = null, + ICreateService? create = null, IAddToRelationshipService? addToRelationship = null, + IUpdateService? update = null, ISetRelationshipService? setRelationship = null, + IDeleteService? delete = null, IRemoveFromRelationshipService? removeFromRelationship = null) + : base(options, resourceGraph, loggerFactory, getAll, getById, getSecondary, getRelationship, create, addToRelationship, update, setRelationship, + delete, removeFromRelationship) + { + } - /// - [HttpGet("{id}/{relationshipName}")] - [HttpHead("{id}/{relationshipName}")] - public override async Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) - { - return await base.GetSecondaryAsync(id, relationshipName, cancellationToken); - } + /// + [HttpGet] + [HttpHead] + public override async Task GetAsync(CancellationToken cancellationToken) + { + return await base.GetAsync(cancellationToken); + } - /// - [HttpGet("{id}/relationships/{relationshipName}")] - [HttpHead("{id}/relationships/{relationshipName}")] - public override async Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) - { - return await base.GetRelationshipAsync(id, relationshipName, cancellationToken); - } + /// + [HttpGet("{id}")] + [HttpHead("{id}")] + public override async Task GetAsync([Required(AllowEmptyStrings = true)] [DisallowNull] TId id, CancellationToken cancellationToken) + { + return await base.GetAsync(id, cancellationToken); + } - /// - [HttpPost] - public override async Task PostAsync([FromBody] TResource resource, CancellationToken cancellationToken) - { - return await base.PostAsync(resource, cancellationToken); - } + /// + [HttpGet("{id}/{relationshipName}")] + [HttpHead("{id}/{relationshipName}")] + public override async Task GetSecondaryAsync([Required(AllowEmptyStrings = true)] [DisallowNull] TId id, [Required] string relationshipName, + CancellationToken cancellationToken) + { + return await base.GetSecondaryAsync(id, relationshipName, cancellationToken); + } - /// - [HttpPost("{id}/relationships/{relationshipName}")] - public override async Task PostRelationshipAsync(TId id, string relationshipName, [FromBody] ISet secondaryResourceIds, - CancellationToken cancellationToken) - { - return await base.PostRelationshipAsync(id, relationshipName, secondaryResourceIds, cancellationToken); - } + /// + [HttpGet("{id}/relationships/{relationshipName}")] + [HttpHead("{id}/relationships/{relationshipName}")] + public override async Task GetRelationshipAsync([Required(AllowEmptyStrings = true)] [DisallowNull] TId id, + [Required] string relationshipName, CancellationToken cancellationToken) + { + return await base.GetRelationshipAsync(id, relationshipName, cancellationToken); + } - /// - [HttpPatch("{id}")] - public override async Task PatchAsync(TId id, [FromBody] TResource resource, CancellationToken cancellationToken) - { - return await base.PatchAsync(id, resource, cancellationToken); - } + /// + [HttpPost] + public override async Task PostAsync([Required] TResource resource, CancellationToken cancellationToken) + { + return await base.PostAsync(resource, cancellationToken); + } - /// - [HttpPatch("{id}/relationships/{relationshipName}")] - public override async Task PatchRelationshipAsync(TId id, string relationshipName, [FromBody] object secondaryResourceIds, - CancellationToken cancellationToken) - { - return await base.PatchRelationshipAsync(id, relationshipName, secondaryResourceIds, cancellationToken); - } + /// + [HttpPost("{id}/relationships/{relationshipName}")] + 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); + } - /// - [HttpDelete("{id}")] - public override async Task DeleteAsync(TId id, CancellationToken cancellationToken) - { - return await base.DeleteAsync(id, cancellationToken); - } + /// + [HttpPatch("{id}")] + public override async Task PatchAsync([Required(AllowEmptyStrings = true)] [DisallowNull] TId id, [Required] TResource resource, + CancellationToken cancellationToken) + { + return await base.PatchAsync(id, resource, cancellationToken); + } - /// - [HttpDelete("{id}/relationships/{relationshipName}")] - public override async Task DeleteRelationshipAsync(TId id, string relationshipName, [FromBody] ISet secondaryResourceIds, - CancellationToken cancellationToken) - { - return await base.DeleteRelationshipAsync(id, relationshipName, secondaryResourceIds, cancellationToken); - } + /// + [HttpPatch("{id}/relationships/{relationshipName}")] + // `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); } /// - public abstract class JsonApiController : JsonApiController - where TResource : class, IIdentifiable + [HttpDelete("{id}")] + public override async Task DeleteAsync([Required(AllowEmptyStrings = true)] [DisallowNull] TId id, CancellationToken cancellationToken) { - /// - protected JsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } + return await base.DeleteAsync(id, cancellationToken); + } - /// - protected JsonApiController(IJsonApiOptions options, ILoggerFactory loggerFactory, IGetAllService getAll = null, - IGetByIdService getById = null, IGetSecondaryService getSecondary = null, - IGetRelationshipService getRelationship = null, ICreateService create = null, - IAddToRelationshipService addToRelationship = null, IUpdateService update = null, - ISetRelationshipService setRelationship = null, IDeleteService delete = null, - IRemoveFromRelationshipService removeFromRelationship = null) - : base(options, loggerFactory, getAll, getById, getSecondary, getRelationship, create, addToRelationship, update, setRelationship, delete, - removeFromRelationship) - { - } + /// + [HttpDelete("{id}/relationships/{relationshipName}")] + 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 7e8e4956ac..90fec3b9f3 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiOperationsController.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; +using System.ComponentModel.DataAnnotations; using JsonApiDotNetCore.AtomicOperations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; @@ -8,25 +6,21 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCore.Controllers +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( + IJsonApiOptions options, IResourceGraph resourceGraph, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request, + ITargetedFields targetedFields, IAtomicOperationFilter operationFilter) + : BaseJsonApiOperationsController(options, resourceGraph, loggerFactory, processor, request, targetedFields, operationFilter) { - /// - /// 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 + /// + [HttpPost] + public override async Task PostOperationsAsync([Required] IList operations, CancellationToken cancellationToken) { - protected JsonApiOperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request, - ITargetedFields targetedFields) - : base(options, loggerFactory, processor, request, targetedFields) - { - } - - /// - [HttpPost] - public override async Task PostOperationsAsync([FromBody] IList operations, CancellationToken cancellationToken) - { - return await base.PostOperationsAsync(operations, cancellationToken); - } + return await base.PostOperationsAsync(operations, cancellationToken); } } diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs index 7ab85612c8..6db14e9fde 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiQueryController.cs @@ -1,72 +1,21 @@ -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; -using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCore.Controllers -{ - /// - /// The base class to derive resource-specific read-only 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 BaseJsonApiController directly. - /// - /// - /// The resource type. - /// - /// - /// The resource identifier type. - /// - public abstract class JsonApiQueryController : BaseJsonApiController - where TResource : class, IIdentifiable - { - /// - /// Creates an instance from a read-only service. - /// - protected JsonApiQueryController(IJsonApiOptions context, ILoggerFactory loggerFactory, IResourceQueryService queryService) - : base(context, loggerFactory, queryService) - { - } +namespace JsonApiDotNetCore.Controllers; - /// - [HttpGet] - public override async Task GetAsync(CancellationToken cancellationToken) - { - return await base.GetAsync(cancellationToken); - } - - /// - [HttpGet("{id}")] - public override async Task GetAsync(TId id, CancellationToken cancellationToken) - { - return await base.GetAsync(id, cancellationToken); - } - - /// - [HttpGet("{id}/{relationshipName}")] - public override async Task GetSecondaryAsync(TId id, string relationshipName, CancellationToken cancellationToken) - { - return await base.GetSecondaryAsync(id, relationshipName, cancellationToken); - } - - /// - [HttpGet("{id}/relationships/{relationshipName}")] - public override async Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) - { - return await base.GetRelationshipAsync(id, relationshipName, cancellationToken); - } - } - - /// - public abstract class JsonApiQueryController : JsonApiQueryController - where TResource : class, IIdentifiable - { - /// - protected JsonApiQueryController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceQueryService queryService) - : base(options, loggerFactory, queryService) - { - } - } -} +/// +/// The base class to derive resource-specific read-only controllers from. Returns HTTP 405 on write-only endpoints. If you want to provide routing +/// templates yourself, you should derive from BaseJsonApiController directly. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +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/ModelStateViolation.cs b/src/JsonApiDotNetCore/Controllers/ModelStateViolation.cs deleted file mode 100644 index 2a4c8cfb84..0000000000 --- a/src/JsonApiDotNetCore/Controllers/ModelStateViolation.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using JetBrains.Annotations; -using Microsoft.AspNetCore.Mvc.ModelBinding; - -namespace JsonApiDotNetCore.Controllers -{ - /// - /// Represents the violation of a model state validation rule. - /// - [PublicAPI] - public sealed class ModelStateViolation - { - public string Prefix { get; } - public string PropertyName { get; } - public Type ResourceType { get; set; } - public ModelError Error { get; } - - public ModelStateViolation(string prefix, string propertyName, Type resourceType, ModelError error) - { - ArgumentGuard.NotNullNorEmpty(prefix, nameof(prefix)); - ArgumentGuard.NotNullNorEmpty(propertyName, nameof(propertyName)); - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - ArgumentGuard.NotNull(error, nameof(error)); - - Prefix = prefix; - PropertyName = propertyName; - ResourceType = resourceType; - Error = error; - } - } -} 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 new file mode 100644 index 0000000000..9b58dc43e7 --- /dev/null +++ b/src/JsonApiDotNetCore/Diagnostics/AspNetCodeTimerSession.cs @@ -0,0 +1,81 @@ +using JetBrains.Annotations; +using Microsoft.AspNetCore.Http; + +namespace JsonApiDotNetCore.Diagnostics; + +/// +/// Code timing session management intended for use in ASP.NET Web Applications. Uses to isolate concurrent requests. +/// Can be used with async/wait, but it cannot distinguish between concurrently running threads within a single HTTP request, so you'll need to pass an +/// instance through the entire call chain in that case. +/// +[PublicAPI] +public sealed class AspNetCodeTimerSession : ICodeTimerSession +{ + private const string HttpContextItemKey = "CascadingCodeTimer:Session"; + + private readonly HttpContext? _httpContext; + private readonly IHttpContextAccessor? _httpContextAccessor; + + public ICodeTimer CodeTimer + { + get + { + HttpContext httpContext = GetHttpContext(); + var codeTimer = (ICodeTimer?)httpContext.Items[HttpContextItemKey]; + + if (codeTimer == null) + { + codeTimer = new CascadingCodeTimer(); + httpContext.Items[HttpContextItemKey] = codeTimer; + } + + return codeTimer; + } + } + + public event EventHandler? Disposed; + + public AspNetCodeTimerSession(IHttpContextAccessor httpContextAccessor) + { + ArgumentNullException.ThrowIfNull(httpContextAccessor); + + _httpContextAccessor = httpContextAccessor; + } + + public AspNetCodeTimerSession(HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext); + + _httpContext = httpContext; + } + + public void Dispose() + { + HttpContext? httpContext = TryGetHttpContext(); + var codeTimer = (ICodeTimer?)httpContext?.Items[HttpContextItemKey]; + + if (codeTimer != null) + { + codeTimer.Dispose(); + httpContext!.Items[HttpContextItemKey] = null; + } + + OnDisposed(); + } + + private void OnDisposed() + { + Disposed?.Invoke(this, EventArgs.Empty); + } + + private HttpContext GetHttpContext() + { + HttpContext? httpContext = TryGetHttpContext(); + return httpContext ?? throw new InvalidOperationException("An active HTTP request is required."); + } + + private HttpContext? TryGetHttpContext() + { + return _httpContext ?? _httpContextAccessor?.HttpContext; + } +} diff --git a/src/JsonApiDotNetCore/Diagnostics/CascadingCodeTimer.cs b/src/JsonApiDotNetCore/Diagnostics/CascadingCodeTimer.cs new file mode 100644 index 0000000000..48109b4c98 --- /dev/null +++ b/src/JsonApiDotNetCore/Diagnostics/CascadingCodeTimer.cs @@ -0,0 +1,286 @@ +using System.Diagnostics; +using System.Globalization; +using System.Runtime.InteropServices; +using System.Text; + +#pragma warning disable CA2000 // Dispose objects before losing scope + +namespace JsonApiDotNetCore.Diagnostics; + +/// +/// Records execution times for nested code blocks. +/// +internal sealed class CascadingCodeTimer : ICodeTimer +{ + private readonly Stopwatch _stopwatch = new(); + private readonly Stack _activeScopeStack = new(); + private readonly List _completedScopes = []; + + static CascadingCodeTimer() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // 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 + + // The most important thing is to prevent switching between CPU cores or processors. Switching dismisses the cache, etc. and has a huge performance impact on the test. + Process.GetCurrentProcess().ProcessorAffinity = new IntPtr(2); + + // To get the CPU core more exclusively, we must prevent that other processes can use this CPU core. We set our process priority to achieve this. + // Note we should NOT set the thread priority, because async/await usage makes the code jump between pooled threads (depending on Synchronization Context). + Process.GetCurrentProcess().PriorityClass = ProcessPriorityClass.High; + } + } + + /// + public IDisposable Measure(string name) + { + return Measure(name, false); + } + + /// + public IDisposable Measure(string name, bool excludeInRelativeCost) + { + MeasureScope childScope = CreateChildScope(name, excludeInRelativeCost); + _activeScopeStack.Push(childScope); + + return childScope; + } + + private MeasureScope CreateChildScope(string name, bool excludeInRelativeCost) + { + if (_activeScopeStack.TryPeek(out MeasureScope? topScope)) + { + return topScope.SpawnChild(this, name, excludeInRelativeCost); + } + + return new MeasureScope(this, name, excludeInRelativeCost); + } + + private void Close(MeasureScope scope) + { + if (!_activeScopeStack.TryPeek(out MeasureScope? topScope) || topScope != scope) + { + throw new InvalidOperationException($"Scope '{scope.Name}' cannot be disposed at this time, because it is not the currently active scope."); + } + + _activeScopeStack.Pop(); + + if (_activeScopeStack.Count == 0) + { + _completedScopes.Add(scope); + } + } + + /// + public string GetResults() + { + int paddingLength = GetPaddingLength(); + + var builder = new StringBuilder(); + WriteResult(builder, paddingLength); + + return builder.ToString(); + } + + private int GetPaddingLength() + { + int maxLength = 0; + + foreach (MeasureScope scope in _completedScopes) + { + int nextLength = scope.GetPaddingLength(); + maxLength = Math.Max(maxLength, nextLength); + } + + if (_activeScopeStack.Count > 0) + { + MeasureScope scope = _activeScopeStack.Peek(); + int nextLength = scope.GetPaddingLength(); + maxLength = Math.Max(maxLength, nextLength); + } + + return maxLength + 3; + } + + private void WriteResult(StringBuilder builder, int paddingLength) + { + foreach (MeasureScope scope in _completedScopes) + { + scope.WriteResult(builder, 0, paddingLength); + } + + if (_activeScopeStack.Count > 0) + { + MeasureScope scope = _activeScopeStack.Peek(); + scope.WriteResult(builder, 0, paddingLength); + } + } + + public void Dispose() + { + if (_stopwatch.IsRunning) + { + _stopwatch.Stop(); + } + + _completedScopes.Clear(); + _activeScopeStack.Clear(); + } + + private sealed class MeasureScope : IDisposable + { + private readonly CascadingCodeTimer _owner; + private readonly List _children = []; + private readonly bool _excludeInRelativeCost; + private readonly TimeSpan _startedAt; + private TimeSpan? _stoppedAt; + + public string Name { get; } + + public MeasureScope(CascadingCodeTimer owner, string name, bool excludeInRelativeCost) + { + _owner = owner; + _excludeInRelativeCost = excludeInRelativeCost; + Name = name; + + EnsureRunning(); + _startedAt = owner._stopwatch.Elapsed; + } + + private void EnsureRunning() + { + if (!_owner._stopwatch.IsRunning) + { + _owner._stopwatch.Start(); + } + } + + public MeasureScope SpawnChild(CascadingCodeTimer owner, string name, bool excludeInRelativeCost) + { + var childScope = new MeasureScope(owner, name, excludeInRelativeCost); + _children.Add(childScope); + return childScope; + } + + public int GetPaddingLength() + { + return GetPaddingLength(0); + } + + private int GetPaddingLength(int indent) + { + int selfLength = indent * 2 + Name.Length; + int maxChildrenLength = 0; + + foreach (MeasureScope child in _children) + { + int nextLength = child.GetPaddingLength(indent + 1); + maxChildrenLength = Math.Max(nextLength, maxChildrenLength); + } + + return Math.Max(selfLength, maxChildrenLength); + } + + private TimeSpan GetElapsedInSelf() + { + return GetElapsedInTotal() - GetElapsedInChildren(); + } + + private TimeSpan GetElapsedInTotal() + { + TimeSpan stoppedAt = _stoppedAt ?? _owner._stopwatch.Elapsed; + return stoppedAt - _startedAt; + } + + private TimeSpan GetElapsedInChildren() + { + TimeSpan elapsedInChildren = TimeSpan.Zero; + + foreach (MeasureScope childScope in _children) + { + elapsedInChildren += childScope.GetElapsedInTotal(); + } + + return elapsedInChildren; + } + + private TimeSpan GetSkippedInTotal() + { + TimeSpan skippedInSelf = _excludeInRelativeCost ? GetElapsedInSelf() : TimeSpan.Zero; + TimeSpan skippedInChildren = GetSkippedInChildren(); + + return skippedInSelf + skippedInChildren; + } + + private TimeSpan GetSkippedInChildren() + { + TimeSpan skippedInChildren = TimeSpan.Zero; + + foreach (MeasureScope childScope in _children) + { + skippedInChildren += childScope.GetSkippedInTotal(); + } + + return skippedInChildren; + } + + public void WriteResult(StringBuilder builder, int indent, int paddingLength) + { + TimeSpan timeElapsedGlobal = GetElapsedInTotal() - GetSkippedInTotal(); + WriteResult(builder, indent, timeElapsedGlobal, paddingLength); + } + + private void WriteResult(StringBuilder builder, int indent, TimeSpan timeElapsedGlobal, int paddingLength) + { + TimeSpan timeElapsedInSelf = GetElapsedInSelf(); + double scaleElapsedInSelf = timeElapsedGlobal != TimeSpan.Zero ? timeElapsedInSelf / timeElapsedGlobal : 0; + + WriteIndent(builder, indent); + builder.Append(Name); + WritePadding(builder, indent, paddingLength); + builder.Append(CultureInfo.InvariantCulture, $"{timeElapsedInSelf,19:G}"); + + if (!_excludeInRelativeCost) + { + builder.Append(" ... "); + builder.Append(CultureInfo.InvariantCulture, $"{scaleElapsedInSelf,7:#0.00%}"); + } + + if (_stoppedAt == null) + { + builder.Append(" (active)"); + } + + builder.AppendLine(); + + foreach (MeasureScope child in _children) + { + child.WriteResult(builder, indent + 1, timeElapsedGlobal, paddingLength); + } + } + + private static void WriteIndent(StringBuilder builder, int indent) + { + builder.Append(new string(' ', indent * 2)); + } + + private void WritePadding(StringBuilder builder, int indent, int paddingLength) + { + string padding = new('.', paddingLength - Name.Length - indent * 2); + builder.Append(' '); + builder.Append(padding); + builder.Append(' '); + } + + public void Dispose() + { + if (_stoppedAt == null) + { + _stoppedAt = _owner._stopwatch.Elapsed; + _owner.Close(this); + } + } + } +} diff --git a/src/JsonApiDotNetCore/Diagnostics/CodeTimingSessionManager.cs b/src/JsonApiDotNetCore/Diagnostics/CodeTimingSessionManager.cs new file mode 100644 index 0000000000..adf642c3b8 --- /dev/null +++ b/src/JsonApiDotNetCore/Diagnostics/CodeTimingSessionManager.cs @@ -0,0 +1,98 @@ +using System.Reflection; + +#pragma warning disable AV1008 // Class should not be static + +namespace JsonApiDotNetCore.Diagnostics; + +/// +/// Provides access to the "current" measurement, which removes the need to pass along a instance through the entire +/// call chain. +/// +public static class CodeTimingSessionManager +{ + public static readonly bool IsEnabled = GetDefaultIsEnabled(); + private static ICodeTimerSession? _session; + + public static ICodeTimer Current + { + get + { + if (!IsEnabled) + { + return DisabledCodeTimer.Instance; + } + + AssertHasActiveSession(); + + return _session!.CodeTimer; + } + } + + private static bool GetDefaultIsEnabled() + { +#if DEBUG + return !IsRunningInTest() && !IsRunningInBenchmark() && !IsGeneratingOpenApiDocumentAtBuildTime(); +#else + return false; +#endif + } + + // ReSharper disable once UnusedMember.Local + private static bool IsRunningInTest() + { + const string testAssemblyName = "xunit.core"; + + return AppDomain.CurrentDomain.GetAssemblies().Any(assembly => + assembly.FullName != null && assembly.FullName.StartsWith(testAssemblyName, StringComparison.Ordinal)); + } + + // ReSharper disable once UnusedMember.Local + 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) + { + throw new InvalidOperationException($"Call {nameof(Capture)} before accessing the current session."); + } + } + + public static void Capture(ICodeTimerSession session) + { + ArgumentNullException.ThrowIfNull(session); + + AssertNoActiveSession(); + + if (IsEnabled) + { + session.Disposed += SessionOnDisposed; + _session = session; + } + } + + private static void AssertNoActiveSession() + { + if (_session != null) + { + throw new InvalidOperationException("Sessions cannot be nested. Dispose the current session first."); + } + } + + private static void SessionOnDisposed(object? sender, EventArgs args) + { + if (_session != null) + { + _session.Disposed -= SessionOnDisposed; + _session = null; + } + } +} diff --git a/src/JsonApiDotNetCore/Diagnostics/DefaultCodeTimerSession.cs b/src/JsonApiDotNetCore/Diagnostics/DefaultCodeTimerSession.cs new file mode 100644 index 0000000000..6ee970711a --- /dev/null +++ b/src/JsonApiDotNetCore/Diagnostics/DefaultCodeTimerSession.cs @@ -0,0 +1,45 @@ +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 a instance through the entire call chain in that case. +/// +public sealed class DefaultCodeTimerSession : ICodeTimerSession +{ + private readonly AsyncLocal _codeTimerInContext = new(); + + public ICodeTimer CodeTimer + { + get + { + AssertNotDisposed(); + + return _codeTimerInContext.Value!; + } + } + + public event EventHandler? Disposed; + + public DefaultCodeTimerSession() + { + _codeTimerInContext.Value = new CascadingCodeTimer(); + } + + private void AssertNotDisposed() + { + ObjectDisposedException.ThrowIf(_codeTimerInContext.Value == null, this); + } + + public void Dispose() + { + _codeTimerInContext.Value?.Dispose(); + _codeTimerInContext.Value = null; + + OnDisposed(); + } + + private void OnDisposed() + { + Disposed?.Invoke(this, EventArgs.Empty); + } +} diff --git a/src/JsonApiDotNetCore/Diagnostics/DisabledCodeTimer.cs b/src/JsonApiDotNetCore/Diagnostics/DisabledCodeTimer.cs new file mode 100644 index 0000000000..a50c25eaba --- /dev/null +++ b/src/JsonApiDotNetCore/Diagnostics/DisabledCodeTimer.cs @@ -0,0 +1,32 @@ +namespace JsonApiDotNetCore.Diagnostics; + +/// +/// Doesn't record anything. Intended for Release builds and to not break existing tests. +/// +internal sealed class DisabledCodeTimer : ICodeTimer +{ + public static readonly DisabledCodeTimer Instance = new(); + + private DisabledCodeTimer() + { + } + + public IDisposable Measure(string name) + { + return this; + } + + public IDisposable Measure(string name, bool excludeInRelativeCost) + { + return this; + } + + public string GetResults() + { + return string.Empty; + } + + public void Dispose() + { + } +} diff --git a/src/JsonApiDotNetCore/Diagnostics/ICodeTimer.cs b/src/JsonApiDotNetCore/Diagnostics/ICodeTimer.cs new file mode 100644 index 0000000000..ff219b5a37 --- /dev/null +++ b/src/JsonApiDotNetCore/Diagnostics/ICodeTimer.cs @@ -0,0 +1,33 @@ +namespace JsonApiDotNetCore.Diagnostics; + +/// +/// Records execution times for code blocks. +/// +public interface ICodeTimer : IDisposable +{ + /// + /// Starts recording the duration of a code block, while including this measurement in calculated percentages. Wrap this call in a using + /// statement, so the recording stops when the return value goes out of scope. + /// + /// + /// Description of what is being recorded. + /// + IDisposable Measure(string name); + + /// + /// Starts recording the duration of a code block. Wrap this call in a using statement, so the recording stops when the return value goes out of + /// scope. + /// + /// + /// Description of what is being recorded. + /// + /// + /// When set, indicates to exclude this measurement in calculated percentages. + /// + IDisposable Measure(string name, bool excludeInRelativeCost); + + /// + /// Returns intermediate or final results. + /// + string GetResults(); +} diff --git a/src/JsonApiDotNetCore/Diagnostics/ICodeTimerSession.cs b/src/JsonApiDotNetCore/Diagnostics/ICodeTimerSession.cs new file mode 100644 index 0000000000..7e631a778c --- /dev/null +++ b/src/JsonApiDotNetCore/Diagnostics/ICodeTimerSession.cs @@ -0,0 +1,11 @@ +namespace JsonApiDotNetCore.Diagnostics; + +/// +/// Removes the need to pass along a instance through the entire call chain when using code timing. +/// +public interface ICodeTimerSession : IDisposable +{ + ICodeTimer CodeTimer { get; } + + event EventHandler Disposed; +} diff --git a/src/JsonApiDotNetCore/Diagnostics/MeasurementSettings.cs b/src/JsonApiDotNetCore/Diagnostics/MeasurementSettings.cs new file mode 100644 index 0000000000..0a92f9d570 --- /dev/null +++ b/src/JsonApiDotNetCore/Diagnostics/MeasurementSettings.cs @@ -0,0 +1,9 @@ +#pragma warning disable AV1008 // Class should not be static + +namespace JsonApiDotNetCore.Diagnostics; + +internal static class MeasurementSettings +{ + public static readonly bool ExcludeDatabaseInPercentages = bool.Parse(bool.TrueString); + public static readonly bool ExcludeJsonSerializationInPercentages = bool.Parse(bool.FalseString); +} diff --git a/src/JsonApiDotNetCore/Errors/CannotClearRequiredRelationshipException.cs b/src/JsonApiDotNetCore/Errors/CannotClearRequiredRelationshipException.cs index f528ed6c96..0a9db40c04 100644 --- a/src/JsonApiDotNetCore/Errors/CannotClearRequiredRelationshipException.cs +++ b/src/JsonApiDotNetCore/Errors/CannotClearRequiredRelationshipException.cs @@ -2,22 +2,15 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Errors -{ - /// - /// The error that is thrown when a required relationship is cleared. - /// - [PublicAPI] - public sealed class CannotClearRequiredRelationshipException : JsonApiException +namespace JsonApiDotNetCore.Errors; + +/// +/// The error that is thrown when a required relationship is cleared. +/// +[PublicAPI] +public sealed class CannotClearRequiredRelationshipException(string relationshipName, string resourceType) + : JsonApiException(new ErrorObject(HttpStatusCode.BadRequest) { - public CannotClearRequiredRelationshipException(string relationshipName, string resourceId, string resourceType) - : base(new Error(HttpStatusCode.BadRequest) - { - Title = "Failed to clear a required relationship.", - Detail = $"The relationship '{relationshipName}' of resource type '{resourceType}' " + - $"with ID '{resourceId}' cannot be cleared because it is a required relationship." - }) - { - } - } -} + 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 new file mode 100644 index 0000000000..689529356b --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/DuplicateLocalIdValueException.cs @@ -0,0 +1,16 @@ +using System.Net; +using JetBrains.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +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(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 new file mode 100644 index 0000000000..53bc2e1e5c --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/FailedOperationException.cs @@ -0,0 +1,20 @@ +using System.Net; +using JetBrains.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +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(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 new file mode 100644 index 0000000000..0e6bfab8cb --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/IncompatibleLocalIdTypeException.cs @@ -0,0 +1,16 @@ +using System.Net; +using JetBrains.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +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(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 ae46c9bad5..5e659bfc05 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidConfigurationException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidConfigurationException.cs @@ -1,17 +1,10 @@ -using System; using JetBrains.Annotations; -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) - { - } - } -} +namespace JsonApiDotNetCore.Errors; + +/// +/// The error that is thrown when configured usage of this library is invalid. +/// +[PublicAPI] +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 de2e9deecb..fc85156e57 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidModelStateException.cs @@ -1,120 +1,371 @@ -using System; -using System.Collections.Generic; using System.Diagnostics; -using System.Linq; using System.Net; using System.Reflection; +using System.Text.Json.Serialization; using JetBrains.Annotations; -using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Mvc.ModelBinding; -using Newtonsoft.Json.Serialization; -namespace JsonApiDotNetCore.Errors +namespace JsonApiDotNetCore.Errors; + +/// +/// The error that is thrown when ASP.NET ModelState validation fails. +/// +[PublicAPI] +public sealed class InvalidModelStateException( + IReadOnlyDictionary modelState, Type modelType, bool includeExceptionStackTraceInErrors, IResourceGraph resourceGraph, + Func? getCollectionElementTypeCallback = null) + : JsonApiException(FromModelStateDictionary(modelState, modelType, resourceGraph, includeExceptionStackTraceInErrors, getCollectionElementTypeCallback)) { - /// - /// The error that is thrown when model state validation fails. - /// - [PublicAPI] - public class InvalidModelStateException : JsonApiException + private static List FromModelStateDictionary(IReadOnlyDictionary modelState, Type modelType, + IResourceGraph resourceGraph, bool includeExceptionStackTraceInErrors, Func? getCollectionElementTypeCallback) { - public InvalidModelStateException(ModelStateDictionary modelState, Type resourceType, bool includeExceptionStackTraceInErrors, - NamingStrategy namingStrategy) - : this(FromModelStateDictionary(modelState, resourceType), includeExceptionStackTraceInErrors, namingStrategy) + ArgumentNullException.ThrowIfNull(modelState); + ArgumentNullException.ThrowIfNull(modelType); + ArgumentNullException.ThrowIfNull(resourceGraph); + + List errorObjects = []; + + foreach ((ModelStateEntry entry, string? sourcePointer) in ResolveSourcePointers(modelState, modelType, resourceGraph, + getCollectionElementTypeCallback)) { + AppendToErrorObjects(entry, errorObjects, sourcePointer, includeExceptionStackTraceInErrors); } - public InvalidModelStateException(IEnumerable violations, bool includeExceptionStackTraceInErrors, NamingStrategy namingStrategy) - : base(FromModelStateViolations(violations, includeExceptionStackTraceInErrors, namingStrategy)) + return errorObjects; + } + + private static IEnumerable<(ModelStateEntry entry, string? sourcePointer)> ResolveSourcePointers(IReadOnlyDictionary modelState, + Type modelType, IResourceGraph resourceGraph, Func? getCollectionElementTypeCallback) + { + foreach (string key in modelState.Keys) { + var rootSegment = ModelStateKeySegment.Create(modelType, key, getCollectionElementTypeCallback); + string? sourcePointer = ResolveSourcePointer(rootSegment, resourceGraph); + + yield return (modelState[key]!, sourcePointer); } + } - private static IEnumerable FromModelStateDictionary(ModelStateDictionary modelState, Type resourceType) + private static string? ResolveSourcePointer(ModelStateKeySegment segment, IResourceGraph resourceGraph) + { + if (segment is ArrayIndexerSegment indexerSegment) { - ArgumentGuard.NotNull(modelState, nameof(modelState)); - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + return ResolveSourcePointerInArrayIndexer(indexerSegment, resourceGraph); + } - var violations = new List(); + if (segment is PropertySegment propertySegment) + { + if (segment.IsInComplexType) + { + return ResolveSourcePointerInComplexType(propertySegment, resourceGraph); + } - foreach ((string propertyName, ModelStateEntry entry) in modelState) + if (propertySegment is { PropertyName: nameof(OperationContainer.Resource), Parent: not null } && + propertySegment.Parent.ModelType == typeof(IList)) { - AddValidationErrors(entry, propertyName, resourceType, violations); + // Special case: Stepping over OperationContainer.Resource property. + + if (segment.GetNextSegment(propertySegment.ModelType, false, $"{segment.SourcePointer}/data") is not PropertySegment nextPropertySegment) + { + return null; + } + + propertySegment = nextPropertySegment; } - return violations; + return ResolveSourcePointerInResourceField(propertySegment, resourceGraph); } - private static void AddValidationErrors(ModelStateEntry entry, string propertyName, Type resourceType, List violations) + return segment.SourcePointer; + } + + private static string? ResolveSourcePointerInArrayIndexer(ArrayIndexerSegment segment, IResourceGraph resourceGraph) + { + string sourcePointer = $"{segment.SourcePointer ?? "/atomic:operations"}[{segment.ArrayIndex}]"; + Type elementType = segment.GetCollectionElementType(); + + ModelStateKeySegment? nextSegment = segment.GetNextSegment(elementType, segment.IsInComplexType, sourcePointer); + return nextSegment != null ? ResolveSourcePointer(nextSegment, resourceGraph) : sourcePointer; + } + + private static string? ResolveSourcePointerInComplexType(PropertySegment segment, IResourceGraph resourceGraph) + { + PropertyInfo? property = segment.ModelType.GetProperty(segment.PropertyName); + + if (property == null) { - foreach (ModelError error in entry.Errors) - { - var violation = new ModelStateViolation("/data/attributes/", propertyName, resourceType, error); - violations.Add(violation); - } + return null; } - private static IEnumerable FromModelStateViolations(IEnumerable violations, bool includeExceptionStackTraceInErrors, - NamingStrategy namingStrategy) + string publicName = PropertySegment.GetPublicNameForProperty(property); + string? sourcePointer = segment.SourcePointer != null ? $"{segment.SourcePointer}/{publicName}" : null; + + ModelStateKeySegment? nextSegment = segment.GetNextSegment(property.PropertyType, true, sourcePointer); + return nextSegment != null ? ResolveSourcePointer(nextSegment, resourceGraph) : sourcePointer; + } + + private static string? ResolveSourcePointerInResourceField(PropertySegment segment, IResourceGraph resourceGraph) + { + ResourceType? resourceType = resourceGraph.FindResourceType(segment.ModelType); + + if (resourceType != null) { - ArgumentGuard.NotNull(violations, nameof(violations)); - ArgumentGuard.NotNull(namingStrategy, nameof(namingStrategy)); + AttrAttribute? attribute = resourceType.FindAttributeByPropertyName(segment.PropertyName); + + if (attribute != null) + { + return ResolveSourcePointerInAttribute(segment, attribute, resourceGraph); + } + + RelationshipAttribute? relationship = resourceType.FindRelationshipByPropertyName(segment.PropertyName); - return violations.SelectMany(violation => FromModelStateViolation(violation, includeExceptionStackTraceInErrors, namingStrategy)); + if (relationship != null) + { + return ResolveSourcePointerInRelationship(segment, relationship, resourceGraph); + } } - private static IEnumerable FromModelStateViolation(ModelStateViolation violation, bool includeExceptionStackTraceInErrors, - NamingStrategy namingStrategy) + return null; + } + + private static string? ResolveSourcePointerInAttribute(PropertySegment segment, AttrAttribute attribute, IResourceGraph resourceGraph) + { + string sourcePointer = attribute.Property.Name == nameof(Identifiable.Id) + ? $"{segment.SourcePointer ?? "/data"}/{attribute.PublicName}" + : $"{segment.SourcePointer ?? "/data"}/attributes/{attribute.PublicName}"; + + ModelStateKeySegment? nextSegment = segment.GetNextSegment(attribute.Property.PropertyType, true, sourcePointer); + return nextSegment != null ? ResolveSourcePointer(nextSegment, resourceGraph) : sourcePointer; + } + + private static string? ResolveSourcePointerInRelationship(PropertySegment segment, RelationshipAttribute relationship, IResourceGraph resourceGraph) + { + string sourcePointer = $"{segment.SourcePointer ?? "/data"}/relationships/{relationship.PublicName}/data"; + + ModelStateKeySegment? nextSegment = segment.GetNextSegment(relationship.RightType.ClrType, false, sourcePointer); + return nextSegment != null ? ResolveSourcePointer(nextSegment, resourceGraph) : sourcePointer; + } + + private static void AppendToErrorObjects(ModelStateEntry entry, List errorObjects, string? sourcePointer, + bool includeExceptionStackTraceInErrors) + { + foreach (ModelError error in entry.Errors) { - if (violation.Error.Exception is JsonApiException jsonApiException) + if (error.Exception is JsonApiException jsonApiException) { - foreach (Error error in jsonApiException.Errors) - { - yield return error; - } + errorObjects.AddRange(jsonApiException.Errors); } else { - string attributeName = GetDisplayNameForProperty(violation.PropertyName, violation.ResourceType, namingStrategy); - string attributePath = violation.Prefix + attributeName; - - yield return FromModelError(violation.Error, attributePath, includeExceptionStackTraceInErrors); + ErrorObject errorObject = FromModelError(error, sourcePointer, includeExceptionStackTraceInErrors); + errorObjects.Add(errorObject); } } + } + + private static ErrorObject FromModelError(ModelError modelError, string? sourcePointer, bool includeExceptionStackTraceInErrors) + { + var error = new ErrorObject(HttpStatusCode.UnprocessableEntity) + { + Title = "Input validation failed.", + Detail = modelError.Exception is TooManyModelErrorsException tooManyException ? tooManyException.Message : modelError.ErrorMessage, + Source = sourcePointer == null + ? null + : new ErrorSource + { + Pointer = sourcePointer + } + }; - private static string GetDisplayNameForProperty(string propertyName, Type resourceType, NamingStrategy namingStrategy) + if (includeExceptionStackTraceInErrors && modelError.Exception != null) { - PropertyInfo property = resourceType.GetProperty(propertyName); + Exception exception = modelError.Exception.Demystify(); + string[] stackTraceLines = exception.ToString().Split(Environment.NewLine); - if (property != null) + if (stackTraceLines.Length > 0) { - var attrAttribute = property.GetCustomAttribute(); - return attrAttribute?.PublicName ?? namingStrategy.GetPropertyName(property.Name, false); + error.Meta ??= new Dictionary(); + error.Meta["StackTrace"] = stackTraceLines; } + } + + return error; + } + + /// + /// Base type that represents a segment in a ModelState key. + /// + private abstract class ModelStateKeySegment + { + private const char Dot = '.'; + private const char BracketOpen = '['; + private const char BracketClose = ']'; + + private static readonly char[] KeySegmentStartTokens = + [ + Dot, + BracketOpen + ]; + + // The right part of the full key, which nested segments are produced from. + private readonly string _nextKey; + + // Enables to resolve the runtime-type of a collection element, such as the resource type in an atomic:operation. + protected Func? GetCollectionElementTypeCallback { get; } + + // In case of a property, its declaring type. In case of an indexer, the collection type or collection element type (in case the parent is a relationship). + public Type ModelType { get; } + + // Indicates we're in a complex object, so to determine public name, inspect [JsonPropertyName] instead of [Attr], [HasOne] etc. + public bool IsInComplexType { get; } + + // The source pointer we've built up, so far. This is null whenever input is not recognized. + public string? SourcePointer { get; } + + public ModelStateKeySegment? Parent { get; } + + protected ModelStateKeySegment(Type modelType, bool isInComplexType, string nextKey, string? sourcePointer, ModelStateKeySegment? parent, + Func? getCollectionElementTypeCallback) + { + ArgumentNullException.ThrowIfNull(modelType); + ArgumentNullException.ThrowIfNull(nextKey); + + ModelType = modelType; + IsInComplexType = isInComplexType; + _nextKey = nextKey; + SourcePointer = sourcePointer; + Parent = parent; + GetCollectionElementTypeCallback = getCollectionElementTypeCallback; + } + + public ModelStateKeySegment? GetNextSegment(Type modelType, bool isInComplexType, string? sourcePointer) + { + ArgumentNullException.ThrowIfNull(modelType); - return propertyName; + return _nextKey.Length == 0 ? null : CreateSegment(modelType, _nextKey, isInComplexType, this, sourcePointer, GetCollectionElementTypeCallback); } - private static Error FromModelError(ModelError modelError, string attributePath, bool includeExceptionStackTraceInErrors) + public static ModelStateKeySegment Create(Type modelType, string key, Func? getCollectionElementTypeCallback) { - var error = new Error(HttpStatusCode.UnprocessableEntity) + ArgumentNullException.ThrowIfNull(modelType); + ArgumentNullException.ThrowIfNull(key); + + return CreateSegment(modelType, key, false, null, null, getCollectionElementTypeCallback); + } + + private static ModelStateKeySegment CreateSegment(Type modelType, string key, bool isInComplexType, ModelStateKeySegment? parent, string? sourcePointer, + Func? getCollectionElementTypeCallback) + { + string? segmentValue = null; + string? nextKey = null; + + int segmentEndIndex = key.IndexOfAny(KeySegmentStartTokens); + + if (segmentEndIndex == 0 && key[0] == BracketOpen) { - Title = "Input validation failed.", - Detail = modelError.ErrorMessage, - Source = attributePath == null - ? null - : new ErrorSource + int bracketCloseIndex = key.IndexOf(BracketClose); + + if (bracketCloseIndex != -1) + { + segmentValue = key[1..bracketCloseIndex]; + + int nextKeyStartIndex = key.Length > bracketCloseIndex + 1 && key[bracketCloseIndex + 1] == Dot + ? bracketCloseIndex + 2 + : bracketCloseIndex + 1; + + nextKey = key[nextKeyStartIndex..]; + + if (int.TryParse(segmentValue, out int indexValue)) { - Pointer = attributePath + return new ArrayIndexerSegment(indexValue, modelType, isInComplexType, nextKey, sourcePointer, parent, + getCollectionElementTypeCallback); } - }; - if (includeExceptionStackTraceInErrors && modelError.Exception != null) + // If the value between brackets is not numeric, consider it an unspeakable property. For example: + // "Foo[Bar]" instead of "Foo.Bar". Its unclear when this happens, but ASP.NET source contains tests for such keys. + } + } + + if (segmentValue == null) + { + segmentValue = segmentEndIndex == -1 ? key : key[..segmentEndIndex]; + + nextKey = segmentEndIndex != -1 && key.Length > segmentEndIndex && key[segmentEndIndex] == Dot + ? key[(segmentEndIndex + 1)..] + : key[segmentValue.Length..]; + } + + // Workaround for a quirk in ModelState validation. Some controller action methods have an 'id' parameter before the [FromBody] parameter. + // When a validation error occurs on top-level 'Id' in the request body, its key contains 'id' instead of 'Id' (the error message is correct, though). + // We compensate for that case here, so that we'll find 'Id' in the resource graph when building the source pointer. + if (segmentValue == "id") + { + segmentValue = "Id"; + } + + return new PropertySegment(segmentValue, modelType, isInComplexType, nextKey!, sourcePointer, parent, getCollectionElementTypeCallback); + } + } + + /// + /// Represents an array indexer in a ModelState key, such as "1" in "Customer.Orders[1].Amount". + /// + 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) + { + public int ArrayIndex { get; } = arrayIndex; + + public Type GetCollectionElementType() + { + Type? type = GetCollectionElementTypeCallback?.Invoke(ModelType, ArrayIndex); + return type ?? GetDeclaredCollectionElementType(); + } + + private Type GetDeclaredCollectionElementType() + { + if (ModelType != typeof(string)) { - error.Meta.IncludeExceptionStackTrace(modelError.Exception.Demystify()); + Type? elementType = CollectionConverter.Instance.FindCollectionElementType(ModelType); + + if (elementType != null) + { + return elementType; + } } - return error; + // In case of a to-many relationship, the ModelType already contains the element type. + return ModelType; + } + } + + /// + /// Represents a property in a ModelState key, such as "Orders" in "Customer.Orders[1].Amount". + /// + private sealed class PropertySegment : ModelStateKeySegment + { + public string PropertyName { get; } + + public PropertySegment(string propertyName, Type modelType, bool isInComplexType, string nextKey, string? sourcePointer, ModelStateKeySegment? parent, + Func? getCollectionElementTypeCallback) + : base(modelType, isInComplexType, nextKey, sourcePointer, parent, getCollectionElementTypeCallback) + { + ArgumentNullException.ThrowIfNull(propertyName); + + PropertyName = propertyName; + } + + public static string GetPublicNameForProperty(PropertyInfo 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 5e704b8e3c..36ae294140 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidQueryException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidQueryException.cs @@ -1,24 +1,17 @@ -using System; using System.Net; using JetBrains.Annotations; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Errors -{ - /// - /// The error that is thrown when translating a to Entity Framework Core fails. - /// - [PublicAPI] - public sealed class InvalidQueryException : JsonApiException +namespace JsonApiDotNetCore.Errors; + +/// +/// The error that is thrown when translating a to Entity Framework Core fails. +/// +[PublicAPI] +public sealed class InvalidQueryException(string reason, Exception? innerException) + : JsonApiException(new ErrorObject(HttpStatusCode.BadRequest) { - public InvalidQueryException(string reason, Exception exception) - : base(new Error(HttpStatusCode.BadRequest) - { - Title = reason, - Detail = exception?.Message - }, exception) - { - } - } -} + Title = reason, + Detail = innerException?.Message + }, innerException); diff --git a/src/JsonApiDotNetCore/Errors/InvalidQueryStringParameterException.cs b/src/JsonApiDotNetCore/Errors/InvalidQueryStringParameterException.cs index 99a4eb381e..d676e40111 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidQueryStringParameterException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidQueryStringParameterException.cs @@ -1,30 +1,23 @@ -using System; using System.Net; using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; -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 QueryParameterName { get; } +namespace JsonApiDotNetCore.Errors; - public InvalidQueryStringParameterException(string queryParameterName, string genericMessage, string specificMessage, Exception innerException = null) - : base(new Error(HttpStatusCode.BadRequest) - { - Title = genericMessage, - Detail = specificMessage, - Source = - { - Parameter = queryParameterName - } - }, innerException) +/// +/// The error that is thrown when processing the request fails due to an error in the request query string. +/// +[PublicAPI] +public sealed class InvalidQueryStringParameterException(string parameterName, string genericMessage, string specificMessage, Exception? innerException = null) + : JsonApiException(new ErrorObject(HttpStatusCode.BadRequest) + { + Title = genericMessage, + Detail = specificMessage, + Source = new ErrorSource { - QueryParameterName = queryParameterName; + Parameter = parameterName } - } + }, innerException) +{ + public string ParameterName { get; } = parameterName; } diff --git a/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs b/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs index fe860ac0fd..6e873eb388 100644 --- a/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs +++ b/src/JsonApiDotNetCore/Errors/InvalidRequestBodyException.cs @@ -1,44 +1,32 @@ -using System; using System.Net; -using System.Text; using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Errors -{ - /// - /// The error that is thrown when deserializing the request body fails. - /// - [PublicAPI] - public sealed class InvalidRequestBodyException : JsonApiException - { - public InvalidRequestBodyException(string reason, string details, string requestBody, Exception innerException = null) - : base(new Error(HttpStatusCode.UnprocessableEntity) - { - Title = reason != null ? "Failed to deserialize request body: " + reason : "Failed to deserialize request body.", - Detail = FormatErrorDetail(details, requestBody, innerException) - }, innerException) - { - } +#pragma warning disable format - private static string FormatErrorDetail(string details, string requestBody, Exception innerException) - { - var builder = new StringBuilder(); - builder.Append(details ?? innerException?.Message); +namespace JsonApiDotNetCore.Errors; - if (requestBody != null) +/// +/// The error that is thrown when deserializing the request body fails. +/// +[PublicAPI] +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 { - if (builder.Length > 0) - { - builder.Append(" - "); - } - - builder.Append("Request body: <<"); - builder.Append(requestBody); - builder.Append(">>"); + Pointer = sourcePointer + }, + Meta = string.IsNullOrEmpty(requestBody) + ? null + : new Dictionary + { + ["RequestBody"] = requestBody } - - return builder.Length > 0 ? builder.ToString() : null; - } - } -} + }, innerException); diff --git a/src/JsonApiDotNetCore/Errors/JsonApiException.cs b/src/JsonApiDotNetCore/Errors/JsonApiException.cs index 93ba6fe6cb..b44b18d5a8 100644 --- a/src/JsonApiDotNetCore/Errors/JsonApiException.cs +++ b/src/JsonApiDotNetCore/Errors/JsonApiException.cs @@ -1,43 +1,51 @@ -using System; -using System.Collections.Generic; -using System.Linq; +using System.Collections.ObjectModel; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; -using Newtonsoft.Json; -namespace JsonApiDotNetCore.Errors +namespace JsonApiDotNetCore.Errors; + +/// +/// The base class for an that represents one or more JSON:API error objects in an unsuccessful response. +/// +[PublicAPI] +public class JsonApiException : Exception { - /// - /// The base class for an that represents one or more JSON:API error objects in an unsuccessful response. - /// - [PublicAPI] - public class JsonApiException : Exception + private static readonly JsonSerializerOptions SerializerOptions = new() { - private static readonly JsonSerializerSettings ErrorSerializerSettings = new JsonSerializerSettings - { - NullValueHandling = NullValueHandling.Ignore, - Formatting = Formatting.Indented - }; + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; - public IReadOnlyList Errors { get; } + public IReadOnlyList Errors { get; } - public override string Message => "Errors = " + JsonConvert.SerializeObject(Errors, ErrorSerializerSettings); + public JsonApiException(ErrorObject error, Exception? innerException = null) + : base(null, innerException) + { + ArgumentNullException.ThrowIfNull(error); - public JsonApiException(Error error, Exception innerException = null) - : base(null, innerException) - { - ArgumentGuard.NotNull(error, nameof(error)); + Errors = [error]; + } - Errors = error.AsArray(); - } + public JsonApiException(IEnumerable errors, Exception? innerException = null) + : base(null, innerException) + { + ReadOnlyCollection? errorCollection = ToCollection(errors); + ArgumentGuard.NotNullNorEmpty(errorCollection, nameof(errors)); - public JsonApiException(IEnumerable errors, Exception innerException = null) - : base(null, innerException) - { - List errorList = errors?.ToList(); - ArgumentGuard.NotNullNorEmpty(errorList, nameof(errors)); + Errors = errorCollection; + } - Errors = errorList; - } + private static ReadOnlyCollection? ToCollection(IEnumerable? errors) + { + return errors?.ToArray().AsReadOnly(); + } + + public string GetSummary() + { + return $"{nameof(JsonApiException)}: Errors = {JsonSerializer.Serialize(Errors, SerializerOptions)}"; } } diff --git a/src/JsonApiDotNetCore/Errors/LocalIdSingleOperationException.cs b/src/JsonApiDotNetCore/Errors/LocalIdSingleOperationException.cs new file mode 100644 index 0000000000..44ca7ecc4c --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/LocalIdSingleOperationException.cs @@ -0,0 +1,16 @@ +using System.Net; +using JetBrains.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Errors; + +/// +/// The error that is thrown when assigning and referencing a local ID within the same operation. +/// +[PublicAPI] +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 7afe5b04cc..de38f61fcd 100644 --- a/src/JsonApiDotNetCore/Errors/MissingResourceInRelationship.cs +++ b/src/JsonApiDotNetCore/Errors/MissingResourceInRelationship.cs @@ -1,23 +1,22 @@ using JetBrains.Annotations; -namespace JsonApiDotNetCore.Errors +namespace JsonApiDotNetCore.Errors; + +[PublicAPI] +public sealed class MissingResourceInRelationship { - [PublicAPI] - public sealed class MissingResourceInRelationship - { - public string RelationshipName { get; } - public string ResourceType { get; } - public string ResourceId { get; } + public string RelationshipName { get; } + public string ResourceType { get; } + public string ResourceId { get; } - public MissingResourceInRelationship(string relationshipName, string resourceType, string resourceId) - { - ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); - ArgumentGuard.NotNullNorEmpty(resourceType, nameof(resourceType)); - ArgumentGuard.NotNullNorEmpty(resourceId, nameof(resourceId)); + public MissingResourceInRelationship(string relationshipName, string resourceType, string resourceId) + { + ArgumentNullException.ThrowIfNull(relationshipName); + ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(resourceId); - RelationshipName = relationshipName; - ResourceType = resourceType; - ResourceId = resourceId; - } + RelationshipName = relationshipName; + ResourceType = resourceType; + ResourceId = resourceId; } } diff --git a/src/JsonApiDotNetCore/Errors/MissingTransactionSupportException.cs b/src/JsonApiDotNetCore/Errors/MissingTransactionSupportException.cs index e092150ba5..c8b182ad53 100644 --- a/src/JsonApiDotNetCore/Errors/MissingTransactionSupportException.cs +++ b/src/JsonApiDotNetCore/Errors/MissingTransactionSupportException.cs @@ -2,21 +2,15 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; -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 +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(string resourceType) + : JsonApiException(new ErrorObject(HttpStatusCode.UnprocessableEntity) { - public MissingTransactionSupportException(string resourceType) - : base(new Error(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." - }) - { - } - } -} + 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 9da29c521b..24b8634f9e 100644 --- a/src/JsonApiDotNetCore/Errors/NonParticipatingTransactionException.cs +++ b/src/JsonApiDotNetCore/Errors/NonParticipatingTransactionException.cs @@ -2,21 +2,15 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; -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 +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(new ErrorObject(HttpStatusCode.UnprocessableEntity) { - public NonParticipatingTransactionException() - : base(new Error(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." - }) - { - } - } -} + 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 864a0c7606..7fc5c92f66 100644 --- a/src/JsonApiDotNetCore/Errors/RelationshipNotFoundException.cs +++ b/src/JsonApiDotNetCore/Errors/RelationshipNotFoundException.cs @@ -2,21 +2,15 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Errors -{ - /// - /// The error that is thrown when a relationship does not exist. - /// - [PublicAPI] - public sealed class RelationshipNotFoundException : JsonApiException +namespace JsonApiDotNetCore.Errors; + +/// +/// The error that is thrown when a relationship does not exist. +/// +[PublicAPI] +public sealed class RelationshipNotFoundException(string relationshipName, string resourceType) + : JsonApiException(new ErrorObject(HttpStatusCode.NotFound) { - public RelationshipNotFoundException(string relationshipName, string resourceType) - : base(new Error(HttpStatusCode.NotFound) - { - Title = "The requested relationship does not exist.", - Detail = $"Resource of type '{resourceType}' does not contain a relationship named '{relationshipName}'." - }) - { - } - } -} + 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/RequestMethodNotAllowedException.cs b/src/JsonApiDotNetCore/Errors/RequestMethodNotAllowedException.cs deleted file mode 100644 index 0a2db9e061..0000000000 --- a/src/JsonApiDotNetCore/Errors/RequestMethodNotAllowedException.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Net; -using System.Net.Http; -using JetBrains.Annotations; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Errors -{ - /// - /// The error that is thrown when a request is received that contains an unsupported HTTP verb. - /// - [PublicAPI] - public sealed class RequestMethodNotAllowedException : JsonApiException - { - public HttpMethod Method { get; } - - public RequestMethodNotAllowedException(HttpMethod method) - : base(new Error(HttpStatusCode.MethodNotAllowed) - { - Title = "The request method is not allowed.", - Detail = $"Resource does not support {method} requests." - }) - { - Method = method; - } - } -} diff --git a/src/JsonApiDotNetCore/Errors/ResourceAlreadyExistsException.cs b/src/JsonApiDotNetCore/Errors/ResourceAlreadyExistsException.cs index 34bc21dff5..518b59ef21 100644 --- a/src/JsonApiDotNetCore/Errors/ResourceAlreadyExistsException.cs +++ b/src/JsonApiDotNetCore/Errors/ResourceAlreadyExistsException.cs @@ -2,21 +2,15 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Errors -{ - /// - /// The error that is thrown when creating a resource with an ID that already exists. - /// - [PublicAPI] - public sealed class ResourceAlreadyExistsException : JsonApiException +namespace JsonApiDotNetCore.Errors; + +/// +/// The error that is thrown when creating a resource with an ID that already exists. +/// +[PublicAPI] +public sealed class ResourceAlreadyExistsException(string resourceId, string resourceType) + : JsonApiException(new ErrorObject(HttpStatusCode.Conflict) { - public ResourceAlreadyExistsException(string resourceId, string resourceType) - : base(new Error(HttpStatusCode.Conflict) - { - Title = "Another resource with the specified ID already exists.", - Detail = $"Another resource of type '{resourceType}' with ID '{resourceId}' already exists." - }) - { - } - } -} + 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/ResourceIdInCreateResourceNotAllowedException.cs b/src/JsonApiDotNetCore/Errors/ResourceIdInCreateResourceNotAllowedException.cs deleted file mode 100644 index 4266f987b3..0000000000 --- a/src/JsonApiDotNetCore/Errors/ResourceIdInCreateResourceNotAllowedException.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Net; -using JetBrains.Annotations; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Errors -{ - /// - /// The error that is thrown when a resource creation request or operation is received that contains a client-generated ID. - /// - [PublicAPI] - public sealed class ResourceIdInCreateResourceNotAllowedException : JsonApiException - { - public ResourceIdInCreateResourceNotAllowedException(int? atomicOperationIndex = null) - : base(new Error(HttpStatusCode.Forbidden) - { - Title = atomicOperationIndex == null - ? "Specifying the resource ID in POST requests is not allowed." - : "Specifying the resource ID in operations that create a resource is not allowed.", - Source = - { - Pointer = atomicOperationIndex != null ? $"/atomic:operations[{atomicOperationIndex}]/data/id" : "/data/id" - } - }) - { - } - } -} diff --git a/src/JsonApiDotNetCore/Errors/ResourceIdMismatchException.cs b/src/JsonApiDotNetCore/Errors/ResourceIdMismatchException.cs deleted file mode 100644 index 721c17e7cd..0000000000 --- a/src/JsonApiDotNetCore/Errors/ResourceIdMismatchException.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Net; -using JetBrains.Annotations; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Errors -{ - /// - /// The error that is thrown when the resource ID in the request body does not match the ID in the current endpoint URL. - /// - [PublicAPI] - public sealed class ResourceIdMismatchException : JsonApiException - { - public ResourceIdMismatchException(string bodyId, string endpointId, string requestPath) - : base(new Error(HttpStatusCode.Conflict) - { - Title = "Resource ID mismatch between request body and endpoint URL.", - Detail = $"Expected resource ID '{endpointId}' in PATCH request body " + $"at endpoint '{requestPath}', instead of '{bodyId}'." - }) - { - } - } -} diff --git a/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs b/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs index 4e63220269..27ac99cafd 100644 --- a/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs +++ b/src/JsonApiDotNetCore/Errors/ResourceNotFoundException.cs @@ -2,21 +2,15 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Errors -{ - /// - /// The error that is thrown when a resource does not exist. - /// - [PublicAPI] - public sealed class ResourceNotFoundException : JsonApiException +namespace JsonApiDotNetCore.Errors; + +/// +/// The error that is thrown when a resource does not exist. +/// +[PublicAPI] +public sealed class ResourceNotFoundException(string resourceId, string resourceType) + : JsonApiException(new ErrorObject(HttpStatusCode.NotFound) { - public ResourceNotFoundException(string resourceId, string resourceType) - : base(new Error(HttpStatusCode.NotFound) - { - Title = "The requested resource does not exist.", - Detail = $"Resource of type '{resourceType}' with ID '{resourceId}' does not exist." - }) - { - } - } -} + Title = "The requested resource does not exist.", + Detail = $"Resource of type '{resourceType}' with ID '{resourceId}' does not exist." + }); diff --git a/src/JsonApiDotNetCore/Errors/ResourceTypeMismatchException.cs b/src/JsonApiDotNetCore/Errors/ResourceTypeMismatchException.cs deleted file mode 100644 index 83f28a14f9..0000000000 --- a/src/JsonApiDotNetCore/Errors/ResourceTypeMismatchException.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System.Net; -using System.Net.Http; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Errors -{ - /// - /// The error that is thrown when the resource type in the request body does not match the type expected at the current endpoint URL. - /// - [PublicAPI] - public sealed class ResourceTypeMismatchException : JsonApiException - { - public ResourceTypeMismatchException(HttpMethod method, string requestPath, ResourceContext expected, ResourceContext actual) - : base(new Error(HttpStatusCode.Conflict) - { - Title = "Resource type mismatch between request body and endpoint URL.", - Detail = $"Expected resource of type '{expected.PublicName}' in {method} " + - $"request body at endpoint '{requestPath}', instead of '{actual?.PublicName}'." - }) - { - } - } -} diff --git a/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipsNotFoundException.cs b/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipsNotFoundException.cs index 94503ab343..81d0a14dc8 100644 --- a/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipsNotFoundException.cs +++ b/src/JsonApiDotNetCore/Errors/ResourcesInRelationshipsNotFoundException.cs @@ -1,30 +1,23 @@ -using System.Collections.Generic; -using System.Linq; using System.Net; using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Errors +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(IEnumerable missingResources) + : JsonApiException(missingResources.Select(CreateError)) { - /// - /// The error that is thrown when referencing one or more non-existing resources in one or more relationships. - /// - [PublicAPI] - public sealed class ResourcesInRelationshipsNotFoundException : JsonApiException + private static ErrorObject CreateError(MissingResourceInRelationship missingResourceInRelationship) { - public ResourcesInRelationshipsNotFoundException(IEnumerable missingResources) - : base(missingResources.Select(CreateError)) - { - } - - private static Error CreateError(MissingResourceInRelationship missingResourceInRelationship) + return new ErrorObject(HttpStatusCode.NotFound) { - return new Error(HttpStatusCode.NotFound) - { - Title = "A related resource does not exist.", - Detail = $"Related resource of type '{missingResourceInRelationship.ResourceType}' with ID '{missingResourceInRelationship.ResourceId}' " + - $"in relationship '{missingResourceInRelationship.RelationshipName}' does not exist." - }; - } + Title = "A related resource does not exist.", + Detail = $"Related resource of type '{missingResourceInRelationship.ResourceType}' with ID '{missingResourceInRelationship.ResourceId}' " + + $"in relationship '{missingResourceInRelationship.RelationshipName}' does not exist." + }; } } diff --git a/src/JsonApiDotNetCore/Errors/RouteNotAvailableException.cs b/src/JsonApiDotNetCore/Errors/RouteNotAvailableException.cs new file mode 100644 index 0000000000..d04d2c599b --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/RouteNotAvailableException.cs @@ -0,0 +1,19 @@ +using System.Net; +using JetBrains.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +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(HttpMethod method, string route) + : JsonApiException(new ErrorObject(HttpStatusCode.Forbidden) + { + 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/ToManyRelationshipRequiredException.cs b/src/JsonApiDotNetCore/Errors/ToManyRelationshipRequiredException.cs deleted file mode 100644 index 840965aed4..0000000000 --- a/src/JsonApiDotNetCore/Errors/ToManyRelationshipRequiredException.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Net; -using JetBrains.Annotations; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Errors -{ - /// - /// The error that is thrown when an attempt is made to update a to-one relationship from a to-many relationship endpoint. - /// - [PublicAPI] - public sealed class ToManyRelationshipRequiredException : JsonApiException - { - public ToManyRelationshipRequiredException(string relationshipName) - : base(new Error(HttpStatusCode.Forbidden) - { - Title = "Only to-many relationships can be updated through this endpoint.", - Detail = $"Relationship '{relationshipName}' must be a to-many relationship." - }) - { - } - } -} diff --git a/src/JsonApiDotNetCore/Errors/UnknownLocalIdValueException.cs b/src/JsonApiDotNetCore/Errors/UnknownLocalIdValueException.cs new file mode 100644 index 0000000000..023f577912 --- /dev/null +++ b/src/JsonApiDotNetCore/Errors/UnknownLocalIdValueException.cs @@ -0,0 +1,16 @@ +using System.Net; +using JetBrains.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Errors; + +/// +/// The error that is thrown when referencing a local ID that hasn't been assigned. +/// +[PublicAPI] +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 87e2a7856b..9e8083f7dc 100644 --- a/src/JsonApiDotNetCore/Errors/UnsuccessfulActionResultException.cs +++ b/src/JsonApiDotNetCore/Errors/UnsuccessfulActionResultException.cs @@ -1,52 +1,68 @@ using System.Net; using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -namespace JsonApiDotNetCore.Errors -{ - /// - /// The error that is thrown when an with non-success status is returned from a controller method. - /// - [PublicAPI] - public sealed class UnsuccessfulActionResultException : JsonApiException - { - public UnsuccessfulActionResultException(HttpStatusCode status) - : base(new Error(status) - { - Title = status.ToString() - }) - { - } +namespace JsonApiDotNetCore.Errors; - public UnsuccessfulActionResultException(ProblemDetails problemDetails) - : base(ToError(problemDetails)) +/// +/// The error that is thrown when an with non-success status is returned from a controller method. +/// +[PublicAPI] +public sealed class UnsuccessfulActionResultException : JsonApiException +{ + public UnsuccessfulActionResultException(HttpStatusCode status) + : base(new ErrorObject(status) { - } + Title = status.ToString() + }) + { + } - private static Error ToError(ProblemDetails problemDetails) - { - ArgumentGuard.NotNull(problemDetails, nameof(problemDetails)); + public UnsuccessfulActionResultException(ProblemDetails problemDetails) + : base(ToErrorObjects(problemDetails)) + { + } - HttpStatusCode status = problemDetails.Status != null ? (HttpStatusCode)problemDetails.Status.Value : HttpStatusCode.InternalServerError; + private static IEnumerable ToErrorObjects(ProblemDetails problemDetails) + { + ArgumentNullException.ThrowIfNull(problemDetails); - var error = new Error(status) - { - Title = problemDetails.Title, - Detail = problemDetails.Detail - }; + HttpStatusCode status = problemDetails.Status != null ? (HttpStatusCode)problemDetails.Status.Value : HttpStatusCode.InternalServerError; - if (!string.IsNullOrWhiteSpace(problemDetails.Instance)) + if (problemDetails is HttpValidationProblemDetails { Errors.Count: > 0 } validationProblemDetails) + { + foreach (string errorMessage in validationProblemDetails.Errors.SelectMany(pair => pair.Value)) { - error.Id = problemDetails.Instance; + yield return ToErrorObject(status, validationProblemDetails, errorMessage); } + } + else + { + yield return ToErrorObject(status, problemDetails, problemDetails.Detail); + } + } - if (!string.IsNullOrWhiteSpace(problemDetails.Type)) - { - error.Links.About = problemDetails.Type; - } + private static ErrorObject ToErrorObject(HttpStatusCode status, ProblemDetails problemDetails, string? detail) + { + var error = new ErrorObject(status) + { + Title = problemDetails.Title, + Detail = detail + }; - return error; + if (!string.IsNullOrWhiteSpace(problemDetails.Instance)) + { + error.Id = problemDetails.Instance; } + + if (!string.IsNullOrWhiteSpace(problemDetails.Type)) + { + error.Links ??= new ErrorLinks(); + error.Links.About = problemDetails.Type; + } + + return error; } } diff --git a/src/JsonApiDotNetCore/Hooks/IResourceHookExecutorFacade.cs b/src/JsonApiDotNetCore/Hooks/IResourceHookExecutorFacade.cs deleted file mode 100644 index 88e812105b..0000000000 --- a/src/JsonApiDotNetCore/Hooks/IResourceHookExecutorFacade.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Hooks.Internal.Execution; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCore.Hooks -{ - /// - /// Facade for execution of resource hooks. - /// - public interface IResourceHookExecutorFacade - { - void BeforeReadSingle(TId id, ResourcePipeline pipeline) - where TResource : class, IIdentifiable; - - void AfterReadSingle(TResource resource, ResourcePipeline pipeline) - where TResource : class, IIdentifiable; - - void BeforeReadMany() - where TResource : class, IIdentifiable; - - void AfterReadMany(IReadOnlyCollection resources) - where TResource : class, IIdentifiable; - - void BeforeCreate(TResource resource) - where TResource : class, IIdentifiable; - - void AfterCreate(TResource resource) - where TResource : class, IIdentifiable; - - void BeforeUpdateResource(TResource resource) - where TResource : class, IIdentifiable; - - void AfterUpdateResource(TResource resource) - where TResource : class, IIdentifiable; - - void BeforeUpdateRelationship(TResource resource) - where TResource : class, IIdentifiable; - - void AfterUpdateRelationship(TResource resource) - where TResource : class, IIdentifiable; - - void BeforeDelete(TId id) - where TResource : class, IIdentifiable; - - void AfterDelete(TId id) - where TResource : class, IIdentifiable; - - void OnReturnSingle(TResource resource, ResourcePipeline pipeline) - where TResource : class, IIdentifiable; - - IReadOnlyCollection OnReturnMany(IReadOnlyCollection resources) - where TResource : class, IIdentifiable; - - object OnReturnRelationship(object resourceOrResources); - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Discovery/HooksDiscovery.cs b/src/JsonApiDotNetCore/Hooks/Internal/Discovery/HooksDiscovery.cs deleted file mode 100644 index b707e2569f..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/Discovery/HooksDiscovery.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using JetBrains.Annotations; -using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Hooks.Internal.Execution; -using JsonApiDotNetCore.Resources; -using Microsoft.Extensions.DependencyInjection; - -namespace JsonApiDotNetCore.Hooks.Internal.Discovery -{ - /// - /// The default implementation for IHooksDiscovery - /// - [PublicAPI] - public class HooksDiscovery : IHooksDiscovery - where TResource : class, IIdentifiable - { - private readonly Type _boundResourceDefinitionType = typeof(ResourceHooksDefinition); - private readonly ResourceHook[] _allHooks; - - private readonly ResourceHook[] _databaseValuesAttributeAllowed = - { - ResourceHook.BeforeUpdate, - ResourceHook.BeforeUpdateRelationship, - ResourceHook.BeforeDelete - }; - - /// - public ResourceHook[] ImplementedHooks { get; private set; } - - public ResourceHook[] DatabaseValuesEnabledHooks { get; private set; } - public ResourceHook[] DatabaseValuesDisabledHooks { get; private set; } - - public HooksDiscovery(IServiceProvider provider) - { - _allHooks = Enum.GetValues(typeof(ResourceHook)).Cast().Where(hook => hook != ResourceHook.None).ToArray(); - - Type containerType; - - using (IServiceScope scope = provider.CreateScope()) - { - containerType = scope.ServiceProvider.GetService(_boundResourceDefinitionType)?.GetType(); - } - - DiscoverImplementedHooks(containerType); - } - - /// - /// Discovers the implemented hooks for a model. - /// - /// - /// The implemented hooks for model. - /// - private void DiscoverImplementedHooks(Type containerType) - { - if (containerType == null || containerType == _boundResourceDefinitionType) - { - return; - } - - var implementedHooks = new List(); - // this hook can only be used with enabled database values - List databaseValuesEnabledHooks = ResourceHook.BeforeImplicitUpdateRelationship.AsList(); - var databaseValuesDisabledHooks = new List(); - - foreach (ResourceHook hook in _allHooks) - { - MethodInfo method = containerType.GetMethod(hook.ToString("G")); - - if (method == null || method.DeclaringType == _boundResourceDefinitionType) - { - continue; - } - - implementedHooks.Add(hook); - LoadDatabaseValuesAttribute attr = method.GetCustomAttributes(true).OfType().SingleOrDefault(); - - if (attr != null) - { - if (!_databaseValuesAttributeAllowed.Contains(hook)) - { - throw new InvalidConfigurationException($"{nameof(LoadDatabaseValuesAttribute)} cannot be used on hook" + - $"{hook:G} in resource definition {containerType.Name}"); - } - - List targetList = attr.Value ? databaseValuesEnabledHooks : databaseValuesDisabledHooks; - targetList.Add(hook); - } - } - - ImplementedHooks = implementedHooks.ToArray(); - DatabaseValuesDisabledHooks = databaseValuesDisabledHooks.ToArray(); - DatabaseValuesEnabledHooks = databaseValuesEnabledHooks.ToArray(); - } - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Discovery/IHooksDiscovery.cs b/src/JsonApiDotNetCore/Hooks/Internal/Discovery/IHooksDiscovery.cs deleted file mode 100644 index c45244b491..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/Discovery/IHooksDiscovery.cs +++ /dev/null @@ -1,30 +0,0 @@ -using JsonApiDotNetCore.Hooks.Internal.Execution; -using JsonApiDotNetCore.Resources; - -// ReSharper disable UnusedTypeParameter - -namespace JsonApiDotNetCore.Hooks.Internal.Discovery -{ - /// - /// A singleton service for a particular TResource that stores a field of enums that represents which resource hooks have been implemented for that - /// particular resource. - /// - public interface IHooksDiscovery : IHooksDiscovery - where TResource : class, IIdentifiable - { - } - - public interface IHooksDiscovery - { - /// - /// A list of the implemented hooks for resource TResource - /// - /// - /// The implemented hooks. - /// - ResourceHook[] ImplementedHooks { get; } - - ResourceHook[] DatabaseValuesEnabledHooks { get; } - ResourceHook[] DatabaseValuesDisabledHooks { get; } - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Discovery/LoadDatabaseValuesAttribute.cs b/src/JsonApiDotNetCore/Hooks/Internal/Discovery/LoadDatabaseValuesAttribute.cs deleted file mode 100644 index 5fe3e71c39..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/Discovery/LoadDatabaseValuesAttribute.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Hooks.Internal.Discovery -{ - [PublicAPI] - [AttributeUsage(AttributeTargets.Method)] - public sealed class LoadDatabaseValuesAttribute : Attribute - { - public bool Value { get; } - - public LoadDatabaseValuesAttribute(bool value = true) - { - Value = value; - } - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Execution/DiffableResourceHashSet.cs b/src/JsonApiDotNetCore/Hooks/Internal/Execution/DiffableResourceHashSet.cs deleted file mode 100644 index 91e51a9660..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/Execution/DiffableResourceHashSet.cs +++ /dev/null @@ -1,87 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using System.Reflection; -using JetBrains.Annotations; -using JsonApiDotNetCore.Hooks.Internal.Discovery; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Hooks.Internal.Execution -{ - [PublicAPI] - public sealed class DiffableResourceHashSet : ResourceHashSet, IDiffableResourceHashSet - where TResource : class, IIdentifiable - { - // ReSharper disable once StaticMemberInGenericType - private static readonly CollectionConverter CollectionConverter = new CollectionConverter(); - - private readonly HashSet _databaseValues; - private readonly bool _databaseValuesLoaded; - private readonly IDictionary> _updatedAttributes; - - public DiffableResourceHashSet(HashSet requestResources, HashSet databaseResources, - IDictionary> relationships, IDictionary> updatedAttributes) - : base(requestResources, relationships) - { - _databaseValues = databaseResources; - _databaseValuesLoaded |= _databaseValues != null; - _updatedAttributes = updatedAttributes; - } - - /// - /// Used internally by the ResourceHookExecutor to make live a bit easier with generics - /// - internal DiffableResourceHashSet(IEnumerable requestResources, IEnumerable databaseResources, - IDictionary relationships, ITargetedFields targetedFields) - : this((HashSet)requestResources, (HashSet)databaseResources, - relationships.ToDictionary(pair => pair.Key, pair => (HashSet)pair.Value), - targetedFields.Attributes?.ToDictionary(attr => attr.Property, _ => (HashSet)requestResources)) - { - } - - /// - public IEnumerable> GetDiffs() - { - if (!_databaseValuesLoaded) - { - ThrowNoDbValuesError(); - } - - foreach (TResource resource in this) - { - TResource currentValueInDatabase = _databaseValues.Single(databaseResource => resource.StringId == databaseResource.StringId); - yield return new ResourceDiffPair(resource, currentValueInDatabase); - } - } - - /// - public override HashSet GetAffected(Expression> navigationAction) - { - ArgumentGuard.NotNull(navigationAction, nameof(navigationAction)); - - PropertyInfo propertyInfo = HooksNavigationParser.ParseNavigationExpression(navigationAction); - Type propertyType = propertyInfo.PropertyType; - - if (propertyType.IsOrImplementsInterface(typeof(IEnumerable))) - { - propertyType = CollectionConverter.TryGetCollectionElementType(propertyType); - } - - if (propertyType.IsOrImplementsInterface(typeof(IIdentifiable))) - { - // the navigation action references a relationship. Redirect the call to the relationship dictionary. - return base.GetAffected(navigationAction); - } - - return _updatedAttributes.TryGetValue(propertyInfo, out HashSet resources) ? resources : new HashSet(); - } - - private void ThrowNoDbValuesError() - { - throw new MemberAccessException($"Cannot iterate over the diffs if the ${nameof(LoadDatabaseValuesAttribute)} option is set to false"); - } - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Execution/HookContainerProvider.cs b/src/JsonApiDotNetCore/Hooks/Internal/Execution/HookContainerProvider.cs deleted file mode 100644 index e926edef72..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/Execution/HookContainerProvider.cs +++ /dev/null @@ -1,261 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Threading; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Hooks.Internal.Discovery; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Repositories; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using LeftType = System.Type; -using RightType = System.Type; - -namespace JsonApiDotNetCore.Hooks.Internal.Execution -{ - /// - internal sealed class HookContainerProvider : IHookContainerProvider - { - private static readonly HooksCollectionConverter CollectionConverter = new HooksCollectionConverter(); - private static readonly HooksObjectFactory ObjectFactory = new HooksObjectFactory(); - private static readonly IncludeChainConverter IncludeChainConverter = new IncludeChainConverter(); - - private readonly IdentifiableComparer _comparer = IdentifiableComparer.Instance; - private readonly IJsonApiOptions _options; - private readonly IGenericServiceFactory _genericServiceFactory; - private readonly IResourceContextProvider _resourceContextProvider; - private readonly Dictionary _hookContainers; - private readonly Dictionary _hookDiscoveries; - private readonly List _targetedHooksForRelatedResources; - - public HookContainerProvider(IGenericServiceFactory genericServiceFactory, IResourceContextProvider resourceContextProvider, IJsonApiOptions options) - { - _options = options; - _genericServiceFactory = genericServiceFactory; - _resourceContextProvider = resourceContextProvider; - _hookContainers = new Dictionary(); - _hookDiscoveries = new Dictionary(); - _targetedHooksForRelatedResources = new List(); - } - - /// - public IResourceHookContainer GetResourceHookContainer(RightType targetResource, ResourceHook hook = ResourceHook.None) - { - // checking the cache if we have a reference for the requested container, - // regardless of the hook we will use it for. If the value is null, - // it means there was no implementation IResourceHookContainer at all, - // so we need not even bother. - if (!_hookContainers.TryGetValue(targetResource, out IResourceHookContainer container)) - { - container = _genericServiceFactory.Get(typeof(ResourceHooksDefinition<>), targetResource); - _hookContainers[targetResource] = container; - } - - if (container == null) - { - return null; - } - - // if there was a container, first check if it implements the hook we - // want to use it for. - IEnumerable targetHooks; - - if (hook == ResourceHook.None) - { - CheckForTargetHookExistence(); - targetHooks = _targetedHooksForRelatedResources; - } - else - { - targetHooks = hook.AsEnumerable(); - } - - foreach (ResourceHook targetHook in targetHooks) - { - if (ShouldExecuteHook(targetResource, targetHook)) - { - return container; - } - } - - return null; - } - - /// - public IResourceHookContainer GetResourceHookContainer(ResourceHook hook = ResourceHook.None) - where TResource : class, IIdentifiable - { - return (IResourceHookContainer)GetResourceHookContainer(typeof(TResource), hook); - } - - public IEnumerable LoadDbValues(LeftType resourceTypeForRepository, IEnumerable resources, params RelationshipAttribute[] relationshipsToNextLayer) - { - LeftType idType = ObjectFactory.GetIdType(resourceTypeForRepository); - - MethodInfo parameterizedGetWhere = - GetType().GetMethod(nameof(GetWhereWithInclude), BindingFlags.NonPublic | BindingFlags.Instance)!.MakeGenericMethod(resourceTypeForRepository, - idType); - - IEnumerable resourceIds = ((IEnumerable)resources).Cast().Select(resource => resource.GetTypedId()); - IList idsAsList = CollectionConverter.CopyToList(resourceIds, idType); - var values = (IEnumerable)parameterizedGetWhere.Invoke(this, ArrayFactory.Create(idsAsList, relationshipsToNextLayer)); - - return values == null ? null : CollectionConverter.CopyToHashSet(values, resourceTypeForRepository); - } - - public bool ShouldLoadDbValues(LeftType resourceType, ResourceHook hook) - { - IHooksDiscovery discovery = GetHookDiscovery(resourceType); - - if (discovery.DatabaseValuesDisabledHooks.Contains(hook)) - { - return false; - } - - if (discovery.DatabaseValuesEnabledHooks.Contains(hook)) - { - return true; - } - - return _options.LoadDatabaseValues; - } - - private bool ShouldExecuteHook(RightType resourceType, ResourceHook hook) - { - IHooksDiscovery discovery = GetHookDiscovery(resourceType); - return discovery.ImplementedHooks.Contains(hook); - } - - private void CheckForTargetHookExistence() - { - if (!_targetedHooksForRelatedResources.Any()) - { - throw new InvalidOperationException("Something is not right in the breadth first traversal of resource hook: " + - "trying to get meta information when no allowed hooks are set"); - } - } - - private IHooksDiscovery GetHookDiscovery(LeftType resourceType) - { - if (!_hookDiscoveries.TryGetValue(resourceType, out IHooksDiscovery discovery)) - { - discovery = _genericServiceFactory.Get(typeof(IHooksDiscovery<>), resourceType); - _hookDiscoveries[resourceType] = discovery; - } - - return discovery; - } - - private IEnumerable GetWhereWithInclude(IReadOnlyCollection ids, RelationshipAttribute[] relationshipsToNextLayer) - where TResource : class, IIdentifiable - { - if (!ids.Any()) - { - return Array.Empty(); - } - - ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(); - FilterExpression filterExpression = CreateFilterByIds(ids, resourceContext); - - var queryLayer = new QueryLayer(resourceContext) - { - Filter = filterExpression - }; - - List chains = relationshipsToNextLayer.Select(relationship => new ResourceFieldChainExpression(relationship)) - .ToList(); - - if (chains.Any()) - { - queryLayer.Include = IncludeChainConverter.FromRelationshipChains(chains); - } - - IResourceReadRepository repository = GetRepository(); - return repository.GetAsync(queryLayer, CancellationToken.None).Result; - } - - private static FilterExpression CreateFilterByIds(IReadOnlyCollection ids, ResourceContext resourceContext) - { - AttrAttribute idAttribute = resourceContext.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); - var idChain = new ResourceFieldChainExpression(idAttribute); - - if (ids.Count == 1) - { - var constant = new LiteralConstantExpression(ids.Single().ToString()); - return new ComparisonExpression(ComparisonOperator.Equals, idChain, constant); - } - - List constants = ids.Select(id => new LiteralConstantExpression(id.ToString())).ToList(); - return new EqualsAnyOfExpression(idChain, constants); - } - - private IResourceReadRepository GetRepository() - where TResource : class, IIdentifiable - { - return _genericServiceFactory.Get>(typeof(IResourceReadRepository<,>), typeof(TResource), typeof(TId)); - } - - public IDictionary LoadImplicitlyAffected(IDictionary leftResourcesByRelation, - IEnumerable existingRightResources = null) - { - List existingRightResourceList = existingRightResources?.Cast().ToList(); - - var implicitlyAffected = new Dictionary(); - - foreach (KeyValuePair pair in leftResourcesByRelation) - { - RelationshipAttribute relationship = pair.Key; - IEnumerable lefts = pair.Value; - - if (relationship is HasManyThroughAttribute) - { - continue; - } - - // note that we don't have to check if BeforeImplicitUpdate hook is implemented. If not, it wont ever get here. - IEnumerable includedLefts = LoadDbValues(relationship.LeftType, lefts, relationship); - - AddToImplicitlyAffected(includedLefts, relationship, existingRightResourceList, implicitlyAffected); - } - - return implicitlyAffected.ToDictionary(pair => pair.Key, pair => CollectionConverter.CopyToHashSet(pair.Value, pair.Key.RightType)); - } - - private void AddToImplicitlyAffected(IEnumerable includedLefts, RelationshipAttribute relationship, List existingRightResourceList, - Dictionary implicitlyAffected) - { - foreach (IIdentifiable ip in includedLefts) - { - object relationshipValue = relationship.GetValue(ip); - ICollection dbRightResources = CollectionConverter.ExtractResources(relationshipValue); - - if (existingRightResourceList != null) - { - dbRightResources = dbRightResources.Except(existingRightResourceList, _comparer).ToList(); - } - - if (dbRightResources.Any()) - { - if (!implicitlyAffected.TryGetValue(relationship, out IEnumerable affected)) - { - affected = CollectionConverter.CopyToList(Array.Empty(), relationship.RightType); - implicitlyAffected[relationship] = affected; - } - - AddToList((IList)affected, dbRightResources); - } - } - } - - private static void AddToList(IList list, IEnumerable itemsToAdd) - { - foreach (object item in itemsToAdd) - { - list.Add(item); - } - } - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Execution/IByAffectedRelationships.cs b/src/JsonApiDotNetCore/Hooks/Internal/Execution/IByAffectedRelationships.cs deleted file mode 100644 index a70b643675..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/Execution/IByAffectedRelationships.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Hooks.Internal.Execution -{ - /// - /// An interface that is implemented to expose a relationship dictionary on another class. - /// - [PublicAPI] - public interface IByAffectedRelationships : IRelationshipGetters - where TRightResource : class, IIdentifiable - { - /// - /// Gets a dictionary of affected resources grouped by affected relationships. - /// - IDictionary> AffectedRelationships { get; } - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Execution/IDiffableResourceHashSet.cs b/src/JsonApiDotNetCore/Hooks/Internal/Execution/IDiffableResourceHashSet.cs deleted file mode 100644 index ab712a0bc6..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/Execution/IDiffableResourceHashSet.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCore.Hooks.Internal.Execution -{ - /// - /// A wrapper class that contains information about the resources that are updated by the request. Contains the resources from the request and the - /// corresponding database values. Also contains information about updated relationships through implementation of IRelationshipsDictionary - /// > - /// - public interface IDiffableResourceHashSet : IResourceHashSet - where TResource : class, IIdentifiable - { - /// - /// Iterates over diffs, which is the affected resource from the request with their associated current value from the database. - /// - IEnumerable> GetDiffs(); - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Execution/IHookContainerProvider.cs b/src/JsonApiDotNetCore/Hooks/Internal/Execution/IHookContainerProvider.cs deleted file mode 100644 index bde89d870a..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/Execution/IHookContainerProvider.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Hooks.Internal.Execution -{ - /// - /// A helper class for retrieving meta data about hooks, fetching database values and performing other recurring internal operations. Used internally by - /// - /// - internal interface IHookContainerProvider - { - /// - /// For a particular ResourceHook and for a given model type, checks if the ResourceHooksDefinition has an implementation for the hook and if so, return - /// it. Also caches the retrieves containers so we don't need to reflectively instantiate them multiple times. - /// - IResourceHookContainer GetResourceHookContainer(Type targetResource, ResourceHook hook = ResourceHook.None); - - /// - /// For a particular ResourceHook and for a given model type, checks if the ResourceHooksDefinition has an implementation for the hook and if so, return - /// it. Also caches the retrieves containers so we don't need to reflectively instantiate them multiple times. - /// - IResourceHookContainer GetResourceHookContainer(ResourceHook hook = ResourceHook.None) - where TResource : class, IIdentifiable; - - /// - /// Load the implicitly affected resources from the database for a given set of target target resources and involved relationships - /// - /// - /// The implicitly affected resources by relationship - /// - IDictionary LoadImplicitlyAffected(IDictionary leftResourcesByRelation, - IEnumerable existingRightResources = null); - - /// - /// For a set of resources, loads current values from the database - /// - /// - /// type of the resources to be loaded - /// - /// - /// The set of resources to load the db values for - /// - /// - /// Relationships that need to be included on resources. - /// - IEnumerable LoadDbValues(Type resourceTypeForRepository, IEnumerable resources, params RelationshipAttribute[] relationships); - - /// - /// Checks if the display database values option is allowed for the targeted hook, and for a given resource of type - /// checks if this hook is implemented and if the database values option is enabled. - /// - /// - /// true, if should load db values, false otherwise. - /// - /// - /// Container resource type. - /// - /// Hook. - bool ShouldLoadDbValues(Type resourceType, ResourceHook hook); - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Execution/IRelationshipGetters.cs b/src/JsonApiDotNetCore/Hooks/Internal/Execution/IRelationshipGetters.cs deleted file mode 100644 index 37e300151c..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/Execution/IRelationshipGetters.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq.Expressions; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Hooks.Internal.Execution -{ - /// - /// A helper class that provides insights in which relationships have been updated for which resources. - /// - [PublicAPI] - public interface IRelationshipGetters - where TLeftResource : class, IIdentifiable - { - /// - /// Gets a dictionary of all resources that have an affected relationship to type - /// - IDictionary> GetByRelationship() - where TRightResource : class, IIdentifiable; - - /// - /// Gets a dictionary of all resources that have an affected relationship to type - /// - IDictionary> GetByRelationship(Type resourceType); - - /// - /// Gets a collection of all the resources for the property within has been affected by the request - /// -#pragma warning disable AV1130 // Return type in method signature should be a collection interface instead of a concrete type - HashSet GetAffected(Expression> navigationAction); -#pragma warning restore AV1130 // Return type in method signature should be a collection interface instead of a concrete type - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Execution/IRelationshipsDictionary.cs b/src/JsonApiDotNetCore/Hooks/Internal/Execution/IRelationshipsDictionary.cs deleted file mode 100644 index 6f1496b01d..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/Execution/IRelationshipsDictionary.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Hooks.Internal.Execution -{ - /// - /// A dummy interface used internally by the hook executor. - /// - public interface IRelationshipsDictionary - { - } - - /// - /// A helper class that provides insights in which relationships have been updated for which resources. - /// - public interface IRelationshipsDictionary - : IRelationshipGetters, IReadOnlyDictionary>, IRelationshipsDictionary - where TRightResource : class, IIdentifiable - { - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Execution/IResourceHashSet.cs b/src/JsonApiDotNetCore/Hooks/Internal/Execution/IResourceHashSet.cs deleted file mode 100644 index 045880be3f..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/Execution/IResourceHashSet.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCore.Hooks.Internal.Execution -{ - /// - /// Basically a enumerable of of resources that were affected by the request. Also contains information about updated - /// relationships through implementation of IAffectedRelationshipsDictionary> - /// - public interface IResourceHashSet : IByAffectedRelationships, IReadOnlyCollection - where TResource : class, IIdentifiable - { - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Execution/RelationshipsDictionary.cs b/src/JsonApiDotNetCore/Hooks/Internal/Execution/RelationshipsDictionary.cs deleted file mode 100644 index 140b0cdd07..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/Execution/RelationshipsDictionary.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using System.Reflection; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Hooks.Internal.Execution -{ - /// - /// Implementation of IAffectedRelationships{TRightResource} It is practically a ReadOnlyDictionary{RelationshipAttribute, HashSet{TRightResource}} - /// dictionary with the two helper methods defined on IAffectedRelationships{TRightResource}. - /// - [PublicAPI] - public class RelationshipsDictionary : Dictionary>, IRelationshipsDictionary - where TResource : class, IIdentifiable - { - /// - /// Initializes a new instance of the class. - /// - /// - /// Relationships. - /// - public RelationshipsDictionary(IDictionary> relationships) - : base(relationships) - { - } - - /// - /// Used internally by the ResourceHookExecutor to make life a bit easier with generics - /// - internal RelationshipsDictionary(IDictionary relationships) - : this(relationships.ToDictionary(pair => pair.Key, pair => (HashSet)pair.Value)) - { - } - - /// - public IDictionary> GetByRelationship() - where TRelatedResource : class, IIdentifiable - { - return GetByRelationship(typeof(TRelatedResource)); - } - - /// - public IDictionary> GetByRelationship(Type resourceType) - { - return this.Where(pair => pair.Key.RightType == resourceType).ToDictionary(pair => pair.Key, pair => pair.Value); - } - - /// - public HashSet GetAffected(Expression> navigationAction) - { - ArgumentGuard.NotNull(navigationAction, nameof(navigationAction)); - - PropertyInfo property = HooksNavigationParser.ParseNavigationExpression(navigationAction); - return this.Where(pair => pair.Key.Property.Name == property.Name).Select(pair => pair.Value).SingleOrDefault(); - } - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourceDiffPair.cs b/src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourceDiffPair.cs deleted file mode 100644 index c338cbe612..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourceDiffPair.cs +++ /dev/null @@ -1,29 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCore.Hooks.Internal.Execution -{ - /// - /// A wrapper that contains a resource that is affected by the request, matched to its current database value - /// - [PublicAPI] - public sealed class ResourceDiffPair - where TResource : class, IIdentifiable - { - /// - /// The resource from the request matching the resource from the database. - /// - public TResource Resource { get; } - - /// - /// The resource from the database matching the resource from the request. - /// - public TResource DatabaseValue { get; } - - public ResourceDiffPair(TResource resource, TResource databaseValue) - { - Resource = resource; - DatabaseValue = databaseValue; - } - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourceHashSet.cs b/src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourceHashSet.cs deleted file mode 100644 index ac6c24bf51..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourceHashSet.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Hooks.Internal.Execution -{ - /// - /// Implementation of IResourceHashSet{TResource}. Basically a enumerable of of resources that were affected by the - /// request. Also contains information about updated relationships through implementation of IRelationshipsDictionary> - /// - [PublicAPI] - public class ResourceHashSet : HashSet, IResourceHashSet - where TResource : class, IIdentifiable - { - private readonly RelationshipsDictionary _relationships; - - /// - public IDictionary> AffectedRelationships => _relationships; - - public ResourceHashSet(HashSet resources, IDictionary> relationships) - : base(resources) - { - _relationships = new RelationshipsDictionary(relationships); - } - - /// - /// Used internally by the ResourceHookExecutor to make live a bit easier with generics - /// - internal ResourceHashSet(IEnumerable resources, IDictionary relationships) - : this((HashSet)resources, relationships.ToDictionary(pair => pair.Key, pair => (HashSet)pair.Value)) - { - } - - /// - public IDictionary> GetByRelationship(Type resourceType) - { - return _relationships.GetByRelationship(resourceType); - } - - /// - public IDictionary> GetByRelationship() - where TRightResource : class, IIdentifiable - { - return GetByRelationship(typeof(TRightResource)); - } - - /// - public virtual HashSet GetAffected(Expression> navigationAction) - { - return _relationships.GetAffected(navigationAction); - } - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourceHook.cs b/src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourceHook.cs deleted file mode 100644 index f63b1380ad..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourceHook.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace JsonApiDotNetCore.Hooks.Internal.Execution -{ - /// - /// A enum that represent the available resource hooks. - /// - public enum ResourceHook - { - None, // https://stackoverflow.com/questions/24151354/is-it-a-good-practice-to-add-a-null-or-none-member-to-the-enum - BeforeCreate, - BeforeRead, - BeforeUpdate, - BeforeDelete, - BeforeUpdateRelationship, - BeforeImplicitUpdateRelationship, - OnReturn, - AfterCreate, - AfterRead, - AfterUpdate, - AfterDelete, - AfterUpdateRelationship - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourcePipeline.cs b/src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourcePipeline.cs deleted file mode 100644 index bdfc01aa78..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/Execution/ResourcePipeline.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Threading; -using JetBrains.Annotations; -using JsonApiDotNetCore.Services; - -namespace JsonApiDotNetCore.Hooks.Internal.Execution -{ - /// - /// An enum that represents the initiator of a resource hook. Eg, when BeforeCreate() is called from - /// , it will be called with parameter pipeline = - /// ResourceAction.GetSingle. - /// - [PublicAPI] - public enum ResourcePipeline - { - None, - Get, - GetSingle, - GetRelationship, - Post, - Patch, - PatchRelationship, - Delete - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/HooksCollectionConverter.cs b/src/JsonApiDotNetCore/Hooks/Internal/HooksCollectionConverter.cs deleted file mode 100644 index 8ccf97ad21..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/HooksCollectionConverter.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; - -namespace JsonApiDotNetCore.Hooks.Internal -{ - internal sealed class HooksCollectionConverter : CollectionConverter - { - public IList CopyToList(IEnumerable elements, Type elementType) - { - Type collectionType = typeof(List<>).MakeGenericType(elementType); - return (IList)CopyToTypedCollection(elements, collectionType); - } - - public IEnumerable CopyToHashSet(IEnumerable elements, Type elementType) - { - Type collectionType = typeof(HashSet<>).MakeGenericType(elementType); - return CopyToTypedCollection(elements, collectionType); - } - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/HooksNavigationParser.cs b/src/JsonApiDotNetCore/Hooks/Internal/HooksNavigationParser.cs deleted file mode 100644 index 30f4c34e1c..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/HooksNavigationParser.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using System.Linq.Expressions; -using System.Reflection; - -#pragma warning disable AV1008 // Class should not be static - -namespace JsonApiDotNetCore.Hooks.Internal -{ - internal static class HooksNavigationParser - { - /// - /// Gets the property info that is referenced in the NavigationAction expression. Credits: https://stackoverflow.com/a/17116267/4441216 - /// - public static PropertyInfo ParseNavigationExpression(Expression> navigationExpression) - { - ArgumentGuard.NotNull(navigationExpression, nameof(navigationExpression)); - - MemberExpression exp; - - // this line is necessary, because sometimes the expression comes in as Convert(originalExpression) - if (navigationExpression.Body is UnaryExpression unaryExpression) - { - if (unaryExpression.Operand is MemberExpression memberExpression) - { - exp = memberExpression; - } - else - { - throw new ArgumentException("Invalid expression.", nameof(navigationExpression)); - } - } - else if (navigationExpression.Body is MemberExpression memberExpression) - { - exp = memberExpression; - } - else - { - throw new ArgumentException("Invalid expression.", nameof(navigationExpression)); - } - - return (PropertyInfo)exp.Member; - } - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/HooksObjectFactory.cs b/src/JsonApiDotNetCore/Hooks/Internal/HooksObjectFactory.cs deleted file mode 100644 index d333d00514..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/HooksObjectFactory.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System; -using System.Reflection; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCore.Hooks.Internal -{ - internal sealed class HooksObjectFactory - { - /// - /// Creates an instance of the specified generic type - /// - /// - /// The instance of the parameterized generic type - /// - /// - /// Generic type parameter to be used in open type. - /// - /// - /// Constructor arguments to be provided in instantiation. - /// - /// - /// Open generic type - /// - public object CreateInstanceOfOpenType(Type openType, Type parameter, params object[] constructorArguments) - { - return CreateInstanceOfOpenType(openType, parameter.AsArray(), constructorArguments); - } - - /// - /// Use this overload if you need to instantiate a type that has an internal constructor - /// - public object CreateInstanceOfInternalOpenType(Type openType, Type parameter, params object[] constructorArguments) - { - Type[] parameters = - { - parameter - }; - - Type closedType = openType.MakeGenericType(parameters); - return Activator.CreateInstance(closedType, BindingFlags.NonPublic | BindingFlags.Instance, null, constructorArguments, null); - } - - /// - /// Creates an instance of the specified generic type - /// - /// - /// The instance of the parameterized generic type - /// - /// - /// Generic type parameters to be used in open type. - /// - /// - /// Constructor arguments to be provided in instantiation. - /// - /// - /// Open generic type - /// - private object CreateInstanceOfOpenType(Type openType, Type[] parameters, params object[] constructorArguments) - { - Type closedType = openType.MakeGenericType(parameters); - return Activator.CreateInstance(closedType, constructorArguments); - } - - /// - /// Gets the type (such as Guid or int) of the Id property on a type that implements . - /// - public Type GetIdType(Type resourceType) - { - PropertyInfo property = resourceType.GetProperty(nameof(Identifiable.Id)); - - if (property == null) - { - throw new ArgumentException($"Type '{resourceType.Name}' does not have 'Id' property."); - } - - return property.PropertyType; - } - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/ICreateHookContainer.cs b/src/JsonApiDotNetCore/Hooks/Internal/ICreateHookContainer.cs deleted file mode 100644 index a52e9a716c..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/ICreateHookContainer.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Hooks.Internal.Execution; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Services; - -namespace JsonApiDotNetCore.Hooks.Internal -{ - /// - /// Create hooks container - /// - public interface ICreateHookContainer - where TResource : class, IIdentifiable - { - /// - /// Implement this hook to run custom logic in the layer just before creation of resources of type - /// . - /// - /// For the pipeline, will typically contain one entry. - /// - /// The returned may be a subset of , in which case the operation of the pipeline will - /// not be executed for the omitted resources. The returned set may also contain custom changes of the properties on the resources. - /// - /// If new relationships are to be created with the to-be-created resources, this will be reflected by the corresponding NavigationProperty being set. - /// For each of these relationships, the hook is fired after the execution of - /// this hook. - /// - /// - /// The transformed resource set - /// - /// - /// The unique set of affected resources. - /// - /// - /// An enum indicating from where the hook was triggered. - /// - IEnumerable BeforeCreate(IResourceHashSet resources, ResourcePipeline pipeline); - - /// - /// Implement this hook to run custom logic in the layer just after creation of resources of type - /// . - /// - /// If relationships were created with the created resources, this will be reflected by the corresponding NavigationProperty being set. For each of these - /// relationships, the - /// hook is fired after the execution of this hook. - /// - /// - /// The transformed resource set - /// - /// - /// The unique set of affected resources. - /// - /// - /// An enum indicating from where the hook was triggered. - /// - void AfterCreate(HashSet resources, ResourcePipeline pipeline); - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/ICreateHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/Internal/ICreateHookExecutor.cs deleted file mode 100644 index c26c5099d8..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/ICreateHookExecutor.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Hooks.Internal.Execution; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Services; - -namespace JsonApiDotNetCore.Hooks.Internal -{ - public interface ICreateHookExecutor - { - /// - /// Executes the Before Cycle by firing the appropriate hooks if they are implemented. The returned set will be used in the actual operation in - /// . - /// - /// Fires the hook for values in parameter . - /// - /// Fires the hook for any secondary (nested) resource for values within - /// parameter - /// - /// - /// The transformed set - /// - /// - /// Target resources for the Before cycle. - /// - /// - /// An enum indicating from where the hook was triggered. - /// - /// - /// The type of the root resources - /// - IEnumerable BeforeCreate(IEnumerable resources, ResourcePipeline pipeline) - where TResource : class, IIdentifiable; - - /// - /// Executes the After Cycle by firing the appropriate hooks if they are implemented. - /// - /// Fires the hook for values in parameter . - /// - /// Fires the hook for any secondary (nested) resource for values within - /// parameter - /// - /// - /// Target resources for the Before cycle. - /// - /// - /// An enum indicating from where the hook was triggered. - /// - /// - /// The type of the root resources - /// - void AfterCreate(IEnumerable resources, ResourcePipeline pipeline) - where TResource : class, IIdentifiable; - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/IDeleteHookContainer.cs b/src/JsonApiDotNetCore/Hooks/Internal/IDeleteHookContainer.cs deleted file mode 100644 index 595056ce07..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/IDeleteHookContainer.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Hooks.Internal.Execution; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Services; - -namespace JsonApiDotNetCore.Hooks.Internal -{ - /// - /// Delete hooks container - /// - public interface IDeleteHookContainer - where TResource : class, IIdentifiable - { - /// - /// Implement this hook to run custom logic in the layer just before deleting resources of type - /// . - /// - /// For the pipeline, will typically contain one resource. - /// - /// The returned may be a subset of , in which case the operation of the pipeline will - /// not be executed for the omitted resources. - /// - /// If by the deletion of these resources any other resources are affected implicitly by the removal of their relationships (eg in the case of an - /// one-to-one relationship), the hook is fired for these resources. - /// - /// - /// The transformed resource set - /// - /// - /// The unique set of affected resources. - /// - /// - /// An enum indicating from where the hook was triggered. - /// - IEnumerable BeforeDelete(IResourceHashSet resources, ResourcePipeline pipeline); - - /// - /// Implement this hook to run custom logic in the layer just after deletion of resources of type - /// . - /// - /// - /// The unique set of affected resources. - /// - /// - /// An enum indicating from where the hook was triggered. - /// - /// - /// If set to true the deletion succeeded in the repository layer. - /// - void AfterDelete(HashSet resources, ResourcePipeline pipeline, bool succeeded); - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/IDeleteHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/Internal/IDeleteHookExecutor.cs deleted file mode 100644 index 706477ad42..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/IDeleteHookExecutor.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Hooks.Internal.Execution; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Services; - -namespace JsonApiDotNetCore.Hooks.Internal -{ - public interface IDeleteHookExecutor - { - /// - /// Executes the Before Cycle by firing the appropriate hooks if they are implemented. The returned set will be used in the actual operation in - /// . - /// - /// Fires the hook for values in parameter . - /// - /// Fires the hook for any resources that are indirectly (implicitly) - /// affected by this operation. Eg: when deleting a resource that has relationships set to other resources, these other resources are implicitly affected - /// by the delete operation. - /// - /// - /// The transformed set - /// - /// - /// Target resources for the Before cycle. - /// - /// - /// An enum indicating from where the hook was triggered. - /// - /// - /// The type of the root resources - /// - IEnumerable BeforeDelete(IEnumerable resources, ResourcePipeline pipeline) - where TResource : class, IIdentifiable; - - /// - /// Executes the After Cycle by firing the appropriate hooks if they are implemented. - /// - /// Fires the hook for values in parameter . - /// - /// - /// Target resources for the Before cycle. - /// - /// - /// An enum indicating from where the hook was triggered. - /// - /// - /// If set to true the deletion succeeded. - /// - /// - /// The type of the root resources - /// - void AfterDelete(IEnumerable resources, ResourcePipeline pipeline, bool succeeded) - where TResource : class, IIdentifiable; - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/IOnReturnHookContainer.cs b/src/JsonApiDotNetCore/Hooks/Internal/IOnReturnHookContainer.cs deleted file mode 100644 index 94b60ead58..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/IOnReturnHookContainer.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Hooks.Internal.Execution; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Services; - -namespace JsonApiDotNetCore.Hooks.Internal -{ - /// - /// On return hook container - /// - public interface IOnReturnHookContainer - where TResource : class, IIdentifiable - { - /// - /// Implement this hook to transform the result data just before returning the resources of type from the - /// layer - /// - /// The returned may be a subset of and may contain changes in properties of the - /// encapsulated resources. - /// - /// - /// - /// The transformed resource set - /// - /// - /// The unique set of affected resources - /// - /// - /// An enum indicating from where the hook was triggered. - /// - IEnumerable OnReturn(HashSet resources, ResourcePipeline pipeline); - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/IOnReturnHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/Internal/IOnReturnHookExecutor.cs deleted file mode 100644 index 9dca66a068..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/IOnReturnHookExecutor.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Hooks.Internal.Execution; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCore.Hooks.Internal -{ - /// - /// Wrapper interface for all On execution methods. - /// - public interface IOnReturnHookExecutor - { - /// - /// Executes the On Cycle by firing the appropriate hooks if they are implemented. - /// - /// Fires the for every unique resource type occurring in parameter - /// . - /// - /// - /// The transformed set - /// - /// - /// Target resources for the Before cycle. - /// - /// - /// An enum indicating from where the hook was triggered. - /// - /// - /// The type of the root resources - /// - IEnumerable OnReturn(IEnumerable resources, ResourcePipeline pipeline) - where TResource : class, IIdentifiable; - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/IReadHookContainer.cs b/src/JsonApiDotNetCore/Hooks/Internal/IReadHookContainer.cs deleted file mode 100644 index d7ad9117a6..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/IReadHookContainer.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Hooks.Internal.Execution; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Services; - -namespace JsonApiDotNetCore.Hooks.Internal -{ - /// - /// Read hooks container - /// - public interface IReadHookContainer - where TResource : class, IIdentifiable - { - /// - /// Implement this hook to run custom logic in the layer just before reading resources of type - /// . - /// - /// - /// An enum indicating from where the hook was triggered. - /// - /// - /// Indicates whether the to be queried resources are the primary request resources or if they were included - /// - /// - /// The string ID of the requested resource, in the case of - /// - void BeforeRead(ResourcePipeline pipeline, bool isIncluded = false, string stringId = null); - - /// - /// Implement this hook to run custom logic in the layer just after reading resources of type - /// . - /// - /// - /// The unique set of affected resources. - /// - /// - /// An enum indicating from where the hook was triggered. - /// - /// - /// A boolean to indicate whether the resources in this hook execution are the primary resources of the request, or if they were included as a - /// relationship - /// - void AfterRead(HashSet resources, ResourcePipeline pipeline, bool isIncluded = false); - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/IReadHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/Internal/IReadHookExecutor.cs deleted file mode 100644 index a752d81e2d..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/IReadHookExecutor.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Collections.Generic; -using System.Threading; -using JsonApiDotNetCore.Hooks.Internal.Execution; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Services; - -namespace JsonApiDotNetCore.Hooks.Internal -{ - /// - /// Wrapper interface for all Before execution methods. - /// - public interface IReadHookExecutor - { - /// - /// Executes the Before Cycle by firing the appropriate hooks if they are implemented. - /// - /// Fires the hook for the requested resources as well as any related relationship. - /// - /// - /// An enum indicating from where the hook was triggered. - /// - /// - /// StringId of the requested resource in the case of . - /// - /// - /// The type of the request resource - /// - void BeforeRead(ResourcePipeline pipeline, string stringId = null) - where TResource : class, IIdentifiable; - - /// - /// Executes the After Cycle by firing the appropriate hooks if they are implemented. - /// - /// Fires the for every unique resource type occurring in parameter - /// . - /// - /// - /// Target resources for the Before cycle. - /// - /// - /// An enum indicating from where the hook was triggered. - /// - /// - /// The type of the root resources - /// - void AfterRead(IEnumerable resources, ResourcePipeline pipeline) - where TResource : class, IIdentifiable; - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/IResourceHookContainer.cs b/src/JsonApiDotNetCore/Hooks/Internal/IResourceHookContainer.cs deleted file mode 100644 index f3bbc0697b..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/IResourceHookContainer.cs +++ /dev/null @@ -1,21 +0,0 @@ -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCore.Hooks.Internal -{ - /// - /// Not meant for public usage. Used internally in the - /// - public interface IResourceHookContainer - { - } - - /// - /// Implement this interface to implement business logic hooks on . - /// - public interface IResourceHookContainer - : IReadHookContainer, IDeleteHookContainer, ICreateHookContainer, IUpdateHookContainer, - IOnReturnHookContainer, IResourceHookContainer - where TResource : class, IIdentifiable - { - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/IResourceHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/Internal/IResourceHookExecutor.cs deleted file mode 100644 index b3baceaf88..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/IResourceHookExecutor.cs +++ /dev/null @@ -1,16 +0,0 @@ -using JsonApiDotNetCore.Hooks.Internal.Execution; -using JsonApiDotNetCore.Hooks.Internal.Traversal; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCore.Hooks.Internal -{ - /// - /// Transient service responsible for executing Resource Hooks as defined in . See methods in - /// , and for more information. Uses - /// for traversal of nested resource data structures. Uses for retrieving meta data - /// about hooks, fetching database values and performing other recurring internal operations. - /// - public interface IResourceHookExecutor : IReadHookExecutor, IUpdateHookExecutor, ICreateHookExecutor, IDeleteHookExecutor, IOnReturnHookExecutor - { - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/IUpdateHookContainer.cs b/src/JsonApiDotNetCore/Hooks/Internal/IUpdateHookContainer.cs deleted file mode 100644 index bcae4b5f9b..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/IUpdateHookContainer.cs +++ /dev/null @@ -1,120 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Hooks.Internal.Execution; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Services; - -namespace JsonApiDotNetCore.Hooks.Internal -{ - /// - /// update hooks container - /// - public interface IUpdateHookContainer - where TResource : class, IIdentifiable - { - /// - /// Implement this hook to run custom logic in the layer just before updating resources of type - /// . - /// - /// For the pipeline, the will typically contain one resource. - /// - /// The returned may be a subset of the property in parameter - /// , in which case the operation of the pipeline will not be executed for the omitted resources. The returned set may also - /// contain custom changes of the properties on the resources. - /// - /// If new relationships are to be created with the to-be-updated resources, this will be reflected by the corresponding NavigationProperty being set. - /// For each of these relationships, the hook is fired after the execution of - /// this hook. - /// - /// If by the creation of these relationships, any other relationships (eg in the case of an already populated one-to-one relationship) are implicitly - /// affected, the hook is fired for these. - /// - /// - /// The transformed resource set - /// - /// - /// The affected resources. - /// - /// - /// An enum indicating from where the hook was triggered. - /// - IEnumerable BeforeUpdate(IDiffableResourceHashSet resources, ResourcePipeline pipeline); - - /// - /// Implement this hook to run custom logic in the layer just before updating relationships to - /// resources of type . - /// - /// This hook is fired when a relationship is created to resources of type from a dependent pipeline ( - /// or ). For example, If an Article was created and its author relationship - /// was set to an existing Person, this hook will be fired for that particular Person. - /// - /// The returned may be a subset of , in which case the operation of the pipeline will not - /// be executed for any resource whose ID was omitted - /// - /// - /// - /// The transformed set of ids - /// - /// - /// The unique set of ids - /// - /// - /// An enum indicating from where the hook was triggered. - /// - /// - /// A helper that groups the resources by the affected relationship - /// - IEnumerable BeforeUpdateRelationship(HashSet ids, IRelationshipsDictionary resourcesByRelationship, - ResourcePipeline pipeline); - - /// - /// Implement this hook to run custom logic in the layer just after updating resources of type - /// . - /// - /// If relationships were updated with the updated resources, this will be reflected by the corresponding NavigationProperty being set. For each of these - /// relationships, the - /// hook is fired after the execution of this hook. - /// - /// - /// The unique set of affected resources. - /// - /// - /// An enum indicating from where the hook was triggered. - /// - void AfterUpdate(HashSet resources, ResourcePipeline pipeline); - - /// - /// Implement this hook to run custom logic in the layer just after a relationship was updated. - /// - /// - /// Relationship helper. - /// - /// - /// An enum indicating from where the hook was triggered. - /// - void AfterUpdateRelationship(IRelationshipsDictionary resourcesByRelationship, ResourcePipeline pipeline); - - /// - /// Implement this hook to run custom logic in the layer just before implicitly updating relationships - /// to resources of type . - /// - /// This hook is fired when a relationship to resources of type is implicitly affected from a dependent pipeline ( - /// or ). For example, if an Article was updated by setting its author - /// relationship (one-to-one) to an existing Person, and by this the relationship to a different Person was implicitly removed, this hook will be fired - /// for the latter Person. - /// - /// See for information about when - /// this hook is fired. - /// - /// - /// - /// The transformed set of ids - /// - /// - /// A helper that groups the resources by the affected relationship - /// - /// - /// An enum indicating from where the hook was triggered. - /// - void BeforeImplicitUpdateRelationship(IRelationshipsDictionary resourcesByRelationship, ResourcePipeline pipeline); - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/IUpdateHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/Internal/IUpdateHookExecutor.cs deleted file mode 100644 index 67323ca1ea..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/IUpdateHookExecutor.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Hooks.Internal.Execution; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Services; - -namespace JsonApiDotNetCore.Hooks.Internal -{ - /// - /// Wrapper interface for all After execution methods. - /// - public interface IUpdateHookExecutor - { - /// - /// Executes the Before Cycle by firing the appropriate hooks if they are implemented. The returned set will be used in the actual operation in - /// . - /// - /// Fires the hook for values in - /// parameter . - /// - /// Fires the hook for any secondary (nested) resource for values within - /// parameter - /// - /// Fires the hook for any resources that are indirectly (implicitly) - /// affected by this operation. Eg: when updating a one-to-one relationship of a resource which already had this relationship populated, then this update - /// will indirectly affect the existing relationship value. - /// - /// - /// The transformed set - /// - /// - /// Target resources for the Before cycle. - /// - /// - /// An enum indicating from where the hook was triggered. - /// - /// - /// The type of the root resources - /// - IEnumerable BeforeUpdate(IEnumerable resources, ResourcePipeline pipeline) - where TResource : class, IIdentifiable; - - /// - /// Executes the After Cycle by firing the appropriate hooks if they are implemented. - /// - /// Fires the hook for values in parameter . - /// - /// Fires the hook for any secondary (nested) resource for values within - /// parameter - /// - /// - /// Target resources for the Before cycle. - /// - /// - /// An enum indicating from where the hook was triggered. - /// - /// - /// The type of the root resources - /// - void AfterUpdate(IEnumerable resources, ResourcePipeline pipeline) - where TResource : class, IIdentifiable; - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/NeverResourceHookExecutorFacade.cs b/src/JsonApiDotNetCore/Hooks/Internal/NeverResourceHookExecutorFacade.cs deleted file mode 100644 index aabff919c1..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/NeverResourceHookExecutorFacade.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Hooks.Internal.Execution; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCore.Hooks.Internal -{ - /// - /// Facade for hooks that never executes any callbacks, which is used when is false. - /// - public sealed class NeverResourceHookExecutorFacade : IResourceHookExecutorFacade - { - public void BeforeReadSingle(TId id, ResourcePipeline pipeline) - where TResource : class, IIdentifiable - { - } - - public void AfterReadSingle(TResource resource, ResourcePipeline pipeline) - where TResource : class, IIdentifiable - { - } - - public void BeforeReadMany() - where TResource : class, IIdentifiable - { - } - - public void AfterReadMany(IReadOnlyCollection resources) - where TResource : class, IIdentifiable - { - } - - public void BeforeCreate(TResource resource) - where TResource : class, IIdentifiable - { - } - - public void AfterCreate(TResource resource) - where TResource : class, IIdentifiable - { - } - - public void BeforeUpdateResource(TResource resource) - where TResource : class, IIdentifiable - { - } - - public void AfterUpdateResource(TResource resource) - where TResource : class, IIdentifiable - { - } - - public void BeforeUpdateRelationship(TResource resource) - where TResource : class, IIdentifiable - { - } - - public void AfterUpdateRelationship(TResource resource) - where TResource : class, IIdentifiable - { - } - - public void BeforeDelete(TId id) - where TResource : class, IIdentifiable - { - } - - public void AfterDelete(TId id) - where TResource : class, IIdentifiable - { - } - - public void OnReturnSingle(TResource resource, ResourcePipeline pipeline) - where TResource : class, IIdentifiable - { - } - - public IReadOnlyCollection OnReturnMany(IReadOnlyCollection resources) - where TResource : class, IIdentifiable - { - return resources; - } - - public object OnReturnRelationship(object resourceOrResources) - { - return resourceOrResources; - } - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutor.cs b/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutor.cs deleted file mode 100644 index e8f51e551d..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutor.cs +++ /dev/null @@ -1,623 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Hooks.Internal.Execution; -using JsonApiDotNetCore.Hooks.Internal.Traversal; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using LeftType = System.Type; -using RightType = System.Type; - -// ReSharper disable PossibleMultipleEnumeration - -namespace JsonApiDotNetCore.Hooks.Internal -{ - /// - internal sealed class ResourceHookExecutor : IResourceHookExecutor - { - private static readonly IncludeChainConverter IncludeChainConverter = new IncludeChainConverter(); - private static readonly HooksObjectFactory ObjectFactory = new HooksObjectFactory(); - private static readonly HooksCollectionConverter CollectionConverter = new HooksCollectionConverter(); - - private readonly IHookContainerProvider _containerProvider; - private readonly INodeNavigator _nodeNavigator; - private readonly IEnumerable _constraintProviders; - private readonly ITargetedFields _targetedFields; - private readonly IResourceGraph _resourceGraph; - - public ResourceHookExecutor(IHookContainerProvider containerProvider, INodeNavigator nodeNavigator, ITargetedFields targetedFields, - IEnumerable constraintProviders, IResourceGraph resourceGraph) - { - _containerProvider = containerProvider; - _nodeNavigator = nodeNavigator; - _targetedFields = targetedFields; - _constraintProviders = constraintProviders; - _resourceGraph = resourceGraph; - } - - /// - public void BeforeRead(ResourcePipeline pipeline, string stringId = null) - where TResource : class, IIdentifiable - { - IResourceHookContainer hookContainer = _containerProvider.GetResourceHookContainer(ResourceHook.BeforeRead); - hookContainer?.BeforeRead(pipeline, false, stringId); - List calledContainers = typeof(TResource).AsList(); - - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - IncludeExpression[] includes = _constraintProviders - .SelectMany(provider => provider.GetConstraints()) - .Select(expressionInScope => expressionInScope.Expression) - .OfType() - .ToArray(); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - foreach (ResourceFieldChainExpression chain in includes.SelectMany(IncludeChainConverter.GetRelationshipChains)) - { - RecursiveBeforeRead(chain.Fields.Cast().ToList(), pipeline, calledContainers); - } - } - - /// - public IEnumerable BeforeUpdate(IEnumerable resources, ResourcePipeline pipeline) - where TResource : class, IIdentifiable - { - GetHookResult result = GetHook(ResourceHook.BeforeUpdate, resources); - - if (result.Succeeded) - { - RelationshipAttribute[] relationships = result.Node.RelationshipsToNextLayer.Select(proxy => proxy.Attribute).ToArray(); - - IEnumerable dbValues = LoadDbValues(typeof(TResource), (IEnumerable)result.Node.UniqueResources, ResourceHook.BeforeUpdate, - relationships); - - var diff = new DiffableResourceHashSet(result.Node.UniqueResources, dbValues, result.Node.LeftsToNextLayer(), _targetedFields); - IEnumerable updated = result.Container.BeforeUpdate(diff, pipeline); - result.Node.UpdateUnique(updated); - result.Node.Reassign(resources); - } - - FireNestedBeforeUpdateHooks(pipeline, _nodeNavigator.CreateNextLayer(result.Node)); - return resources; - } - - /// - public IEnumerable BeforeCreate(IEnumerable resources, ResourcePipeline pipeline) - where TResource : class, IIdentifiable - { - GetHookResult result = GetHook(ResourceHook.BeforeCreate, resources); - - if (result.Succeeded) - { - var affected = new ResourceHashSet((HashSet)result.Node.UniqueResources, result.Node.LeftsToNextLayer()); - IEnumerable updated = result.Container.BeforeCreate(affected, pipeline); - result.Node.UpdateUnique(updated); - result.Node.Reassign(resources); - } - - FireNestedBeforeUpdateHooks(pipeline, _nodeNavigator.CreateNextLayer(result.Node)); - return resources; - } - - /// - public IEnumerable BeforeDelete(IEnumerable resources, ResourcePipeline pipeline) - where TResource : class, IIdentifiable - { - GetHookResult result = GetHook(ResourceHook.BeforeDelete, resources); - - if (result.Succeeded) - { - RelationshipAttribute[] relationships = result.Node.RelationshipsToNextLayer.Select(proxy => proxy.Attribute).ToArray(); - - IEnumerable targetResources = - LoadDbValues(typeof(TResource), (IEnumerable)result.Node.UniqueResources, ResourceHook.BeforeDelete, relationships) ?? - result.Node.UniqueResources; - - var affected = new ResourceHashSet(targetResources, result.Node.LeftsToNextLayer()); - - IEnumerable updated = result.Container.BeforeDelete(affected, pipeline); - result.Node.UpdateUnique(updated); - result.Node.Reassign(resources); - } - - // If we're deleting an article, we're implicitly affected any owners related to it. - // Here we're loading all relations onto the to-be-deleted article - // if for that relation the BeforeImplicitUpdateHook is implemented, - // and this hook is then executed - foreach (KeyValuePair> entry in result.Node.LeftsToNextLayerByRelationships()) - { - Type rightType = entry.Key; - Dictionary implicitTargets = entry.Value; - FireForAffectedImplicits(rightType, implicitTargets, pipeline); - } - - return resources; - } - - /// - public IEnumerable OnReturn(IEnumerable resources, ResourcePipeline pipeline) - where TResource : class, IIdentifiable - { - GetHookResult result = GetHook(ResourceHook.OnReturn, resources); - - if (result.Succeeded) - { - IEnumerable updated = result.Container.OnReturn((HashSet)result.Node.UniqueResources, pipeline); - ValidateHookResponse(updated); - result.Node.UpdateUnique(updated); - result.Node.Reassign(resources); - } - - TraverseNodesInLayer(_nodeNavigator.CreateNextLayer(result.Node), ResourceHook.OnReturn, (nextContainer, nextNode) => - { - IEnumerable filteredUniqueSet = CallHook(nextContainer, ResourceHook.OnReturn, ArrayFactory.Create(nextNode.UniqueResources, pipeline)); - nextNode.UpdateUnique(filteredUniqueSet); - nextNode.Reassign(); - }); - - return resources; - } - - /// - public void AfterRead(IEnumerable resources, ResourcePipeline pipeline) - where TResource : class, IIdentifiable - { - GetHookResult result = GetHook(ResourceHook.AfterRead, resources); - - if (result.Succeeded) - { - result.Container.AfterRead((HashSet)result.Node.UniqueResources, pipeline); - } - - TraverseNodesInLayer(_nodeNavigator.CreateNextLayer(result.Node), ResourceHook.AfterRead, (nextContainer, nextNode) => - { - CallHook(nextContainer, ResourceHook.AfterRead, ArrayFactory.Create(nextNode.UniqueResources, pipeline, true)); - }); - } - - /// - public void AfterCreate(IEnumerable resources, ResourcePipeline pipeline) - where TResource : class, IIdentifiable - { - GetHookResult result = GetHook(ResourceHook.AfterCreate, resources); - - if (result.Succeeded) - { - result.Container.AfterCreate((HashSet)result.Node.UniqueResources, pipeline); - } - - TraverseNodesInLayer(_nodeNavigator.CreateNextLayer(result.Node), ResourceHook.AfterUpdateRelationship, - (nextContainer, nextNode) => FireAfterUpdateRelationship(nextContainer, nextNode, pipeline)); - } - - /// - public void AfterUpdate(IEnumerable resources, ResourcePipeline pipeline) - where TResource : class, IIdentifiable - { - GetHookResult result = GetHook(ResourceHook.AfterUpdate, resources); - - if (result.Succeeded) - { - result.Container.AfterUpdate((HashSet)result.Node.UniqueResources, pipeline); - } - - TraverseNodesInLayer(_nodeNavigator.CreateNextLayer(result.Node), ResourceHook.AfterUpdateRelationship, - (nextContainer, nextNode) => FireAfterUpdateRelationship(nextContainer, nextNode, pipeline)); - } - - /// - public void AfterDelete(IEnumerable resources, ResourcePipeline pipeline, bool succeeded) - where TResource : class, IIdentifiable - { - GetHookResult result = GetHook(ResourceHook.AfterDelete, resources); - - if (result.Succeeded) - { - result.Container.AfterDelete((HashSet)result.Node.UniqueResources, pipeline, succeeded); - } - } - - /// - /// For a given target and for a given type , gets the hook container if the target hook was - /// implemented and should be executed. - /// - /// Along the way, creates a traversable node from the root resource set. - /// - /// - /// true, if hook was implemented, false otherwise. - /// - private GetHookResult GetHook(ResourceHook target, IEnumerable resources) - where TResource : class, IIdentifiable - { - RootNode node = _nodeNavigator.CreateRootNode(resources); - IResourceHookContainer container = _containerProvider.GetResourceHookContainer(target); - - return new GetHookResult(container, node); - } - - private void TraverseNodesInLayer(IEnumerable currentLayer, ResourceHook target, Action action) - { - IEnumerable nextLayer = currentLayer; - - while (true) - { - if (!HasResources(nextLayer)) - { - return; - } - - TraverseNextLayer(nextLayer, action, target); - - nextLayer = _nodeNavigator.CreateNextLayer(nextLayer.ToList()); - } - } - - private static bool HasResources(IEnumerable layer) - { - return layer.Any(node => node.UniqueResources.Cast().Any()); - } - - private void TraverseNextLayer(IEnumerable nextLayer, Action action, ResourceHook target) - { - foreach (IResourceNode node in nextLayer) - { - IResourceHookContainer hookContainer = _containerProvider.GetResourceHookContainer(node.ResourceType, target); - - if (hookContainer != null) - { - action(hookContainer, node); - } - } - } - - /// - /// Recursively goes through the included relationships from JsonApiContext, translates them to the corresponding hook containers and fires the - /// BeforeRead hook (if implemented) - /// - private void RecursiveBeforeRead(List relationshipChain, ResourcePipeline pipeline, List calledContainers) - { - while (true) - { - RelationshipAttribute relationship = relationshipChain.First(); - - if (!calledContainers.Contains(relationship.RightType)) - { - calledContainers.Add(relationship.RightType); - IResourceHookContainer container = _containerProvider.GetResourceHookContainer(relationship.RightType, ResourceHook.BeforeRead); - - if (container != null) - { - CallHook(container, ResourceHook.BeforeRead, new object[] - { - pipeline, - true, - null - }); - } - } - - relationshipChain.RemoveAt(0); - - if (!relationshipChain.Any()) - { - break; - } - } - } - - /// - /// Fires the nested before hooks for resources in the current - /// - /// - /// For example: consider the case when the owner of article1 (one-to-one) is being updated from owner_old to owner_new, where owner_new is currently - /// already related to article2. Then, the following nested hooks need to be fired in the following order. First the BeforeUpdateRelationship should be - /// for owner1, then the BeforeImplicitUpdateRelationship hook should be fired for owner2, and lastly the BeforeImplicitUpdateRelationship for article2. - /// - private void FireNestedBeforeUpdateHooks(ResourcePipeline pipeline, IEnumerable layer) - { - foreach (IResourceNode node in layer) - { - IResourceHookContainer nestedHookContainer = - _containerProvider.GetResourceHookContainer(node.ResourceType, ResourceHook.BeforeUpdateRelationship); - - IEnumerable uniqueResources = node.UniqueResources; - RightType resourceType = node.ResourceType; - IDictionary currentResourcesGrouped; - IDictionary currentResourcesGroupedInverse; - - // fire the BeforeUpdateRelationship hook for owner_new - if (nestedHookContainer != null) - { - if (uniqueResources.Cast().Any()) - { - RelationshipAttribute[] relationships = node.RelationshipsToNextLayer.Select(proxy => proxy.Attribute).ToArray(); - IEnumerable dbValues = LoadDbValues(resourceType, uniqueResources, ResourceHook.BeforeUpdateRelationship, relationships); - - // these are the resources of the current node grouped by - // RelationshipAttributes that occurred in the previous layer - // so it looks like { HasOneAttribute:owner => owner_new }. - // Note that in the BeforeUpdateRelationship hook of Person, - // we want want inverse relationship attribute: - // we now have the one pointing from article -> person, ] - // but we require the the one that points from person -> article - currentResourcesGrouped = node.RelationshipsFromPreviousLayer.GetRightResources(); - currentResourcesGroupedInverse = ReplaceKeysWithInverseRelationships(currentResourcesGrouped); - - IRelationshipsDictionary resourcesByRelationship = CreateRelationshipHelper(resourceType, currentResourcesGroupedInverse, dbValues); - - IEnumerable allowedIds = CallHook(nestedHookContainer, ResourceHook.BeforeUpdateRelationship, - ArrayFactory.Create(GetIds(uniqueResources), resourcesByRelationship, pipeline)).Cast(); - - ISet updated = GetAllowedResources(uniqueResources, allowedIds); - node.UpdateUnique(updated); - node.Reassign(); - } - } - - // Fire the BeforeImplicitUpdateRelationship hook for owner_old. - // Note: if the pipeline is Post it means we just created article1, - // which means we are sure that it isn't related to any other resources yet. - if (pipeline != ResourcePipeline.Post) - { - // To fire a hook for owner_old, we need to first get a reference to it. - // For this, we need to query the database for the HasOneAttribute:owner - // relationship of article1, which is referred to as the - // left side of the HasOneAttribute:owner relationship. - IDictionary leftResources = node.RelationshipsFromPreviousLayer.GetLeftResources(); - - if (leftResources.Any()) - { - // owner_old is loaded, which is an "implicitly affected resource" - FireForAffectedImplicits(resourceType, leftResources, pipeline, uniqueResources); - } - } - - // Fire the BeforeImplicitUpdateRelationship hook for article2 - // For this, we need to query the database for the current owner - // relationship value of owner_new. - currentResourcesGrouped = node.RelationshipsFromPreviousLayer.GetRightResources(); - - if (currentResourcesGrouped.Any()) - { - // rightResources is grouped by relationships from previous - // layer, ie { HasOneAttribute:owner => owner_new }. But - // to load article2 onto owner_new, we need to have the - // RelationshipAttribute from owner to article, which is the - // inverse of HasOneAttribute:owner - currentResourcesGroupedInverse = ReplaceKeysWithInverseRelationships(currentResourcesGrouped); - // Note that currently in the JsonApiDotNetCore implementation of hooks, - // the root layer is ALWAYS homogenous, so we safely assume - // that for every relationship to the previous layer, the - // left type is the same. - LeftType leftType = currentResourcesGrouped.First().Key.LeftType; - FireForAffectedImplicits(leftType, currentResourcesGroupedInverse, pipeline); - } - } - } - - /// - /// replaces the keys of the dictionary with its inverse relationship attribute. - /// - /// - /// Resources grouped by relationship attribute - /// - private IDictionary ReplaceKeysWithInverseRelationships( - IDictionary resourcesByRelationship) - { - // when Article has one Owner (HasOneAttribute:owner) is set, there is no guarantee - // that the inverse attribute was also set (Owner has one Article: HasOneAttr:article). - // If it isn't, JsonApiDotNetCore currently knows nothing about this relationship pointing back, and it - // currently cannot fire hooks for resources resolved through inverse relationships. - IEnumerable> inversableRelationshipAttributes = - resourcesByRelationship.Where(pair => pair.Key.InverseNavigationProperty != null); - - return inversableRelationshipAttributes.ToDictionary(pair => _resourceGraph.GetInverseRelationship(pair.Key), pair => pair.Value); - } - - /// - /// Given a source of resources, gets the implicitly affected resources from the database and calls the BeforeImplicitUpdateRelationship hook. - /// - private void FireForAffectedImplicits(Type resourceTypeToInclude, IDictionary implicitsTarget, - ResourcePipeline pipeline, IEnumerable existingImplicitResources = null) - { - IResourceHookContainer container = - _containerProvider.GetResourceHookContainer(resourceTypeToInclude, ResourceHook.BeforeImplicitUpdateRelationship); - - if (container == null) - { - return; - } - - IDictionary implicitAffected = - _containerProvider.LoadImplicitlyAffected(implicitsTarget, existingImplicitResources); - - if (!implicitAffected.Any()) - { - return; - } - - Dictionary inverse = - implicitAffected.ToDictionary(pair => _resourceGraph.GetInverseRelationship(pair.Key), pair => pair.Value); - - IRelationshipsDictionary resourcesByRelationship = CreateRelationshipHelper(resourceTypeToInclude, inverse); - CallHook(container, ResourceHook.BeforeImplicitUpdateRelationship, ArrayFactory.Create(resourcesByRelationship, pipeline)); - } - - /// - /// checks that the collection does not contain more than one item when relevant (eg AfterRead from GetSingle pipeline). - /// - /// - /// The collection returned from the hook - /// - /// - /// The pipeline from which the hook was fired - /// - [AssertionMethod] - private void ValidateHookResponse(IEnumerable returnedList, ResourcePipeline pipeline = 0) - { - if (pipeline == ResourcePipeline.GetSingle && returnedList.Count() > 1) - { - throw new InvalidOperationException("The returned collection from this hook may contain at most one item in the case of the " + - pipeline.ToString("G") + " pipeline"); - } - } - - /// - /// A helper method to call a hook on reflectively. - /// - private IEnumerable CallHook(IResourceHookContainer container, ResourceHook hook, object[] arguments) - { - MethodInfo method = container.GetType().GetMethod(hook.ToString("G")); - // note that some of the hooks return "void". When these hooks, the - // are called reflectively with Invoke like here, the return value - // is just null, so we don't have to worry about casting issues here. - return (IEnumerable)ThrowJsonApiExceptionOnError(() => method?.Invoke(container, arguments)); - } - - /// - /// If the method, unwrap and throw the actual exception. - /// - private object ThrowJsonApiExceptionOnError(Func action) - { - try - { - return action(); - } - catch (TargetInvocationException tie) when (tie.InnerException != null) - { - throw tie.InnerException; - } - } - - /// - /// Helper method to instantiate AffectedRelationships for a given If are included, the - /// values of the entries in need to be replaced with these values. - /// - /// - /// The relationship helper. - /// - private IRelationshipsDictionary CreateRelationshipHelper(RightType resourceType, - IDictionary prevLayerRelationships, IEnumerable dbValues = null) - { - IDictionary prevLayerRelationshipsWithDbValues = prevLayerRelationships; - - if (dbValues != null) - { - prevLayerRelationshipsWithDbValues = ReplaceWithDbValues(prevLayerRelationshipsWithDbValues, dbValues.Cast()); - } - - return (IRelationshipsDictionary)ObjectFactory.CreateInstanceOfInternalOpenType(typeof(RelationshipsDictionary<>), resourceType, - prevLayerRelationshipsWithDbValues); - } - - /// - /// Replaces the resources in the values of the prevLayerRelationships dictionary with the corresponding resources loaded from the db. - /// - private IDictionary ReplaceWithDbValues(IDictionary prevLayerRelationships, - IEnumerable dbValues) - { - foreach (RelationshipAttribute key in prevLayerRelationships.Keys.ToList()) - { - IEnumerable source = prevLayerRelationships[key].Cast().Select(resource => - dbValues.Single(dbResource => dbResource.StringId == resource.StringId)); - - prevLayerRelationships[key] = CollectionConverter.CopyToHashSet(source, key.LeftType); - } - - return prevLayerRelationships; - } - - /// - /// Filter the source set by removing the resources with ID that are not in . - /// - private ISet GetAllowedResources(IEnumerable source, IEnumerable allowedIds) - { - return new HashSet(source.Cast().Where(ue => allowedIds.Contains(ue.StringId))); - } - - /// - /// given the set of , it will load all the values from the database of these resources. - /// - /// - /// The db values. - /// - /// - /// type of the resources to be loaded - /// - /// - /// The set of resources to load the db values for - /// - /// - /// The hook in which the db values will be displayed. - /// - /// - /// Relationships from to the next layer: this indicates which relationships will be included on - /// . - /// - private IEnumerable LoadDbValues(Type resourceType, IEnumerable uniqueResources, ResourceHook targetHook, - RelationshipAttribute[] relationshipsToNextLayer) - { - // We only need to load database values if the target hook of this hook execution - // cycle is compatible with displaying database values and has this option enabled. - if (!_containerProvider.ShouldLoadDbValues(resourceType, targetHook)) - { - return null; - } - - return _containerProvider.LoadDbValues(resourceType, uniqueResources, relationshipsToNextLayer); - } - - /// - /// Fires the AfterUpdateRelationship hook - /// - private void FireAfterUpdateRelationship(IResourceHookContainer container, IResourceNode node, ResourcePipeline pipeline) - { - IDictionary currentResourcesGrouped = node.RelationshipsFromPreviousLayer.GetRightResources(); - - // the relationships attributes in currentResourcesGrouped will be pointing from a - // resource in the previous layer to a resource in the current (nested) layer. - // For the nested hook we need to replace these attributes with their inverse. - // See the FireNestedBeforeUpdateHooks method for a more detailed example. - IRelationshipsDictionary resourcesByRelationship = - CreateRelationshipHelper(node.ResourceType, ReplaceKeysWithInverseRelationships(currentResourcesGrouped)); - - CallHook(container, ResourceHook.AfterUpdateRelationship, ArrayFactory.Create(resourcesByRelationship, pipeline)); - } - - /// - /// Returns a list of StringIds from a list of IIdentifiable resources (). - /// - /// The ids. - /// - /// IIdentifiable resources. - /// - private ISet GetIds(IEnumerable resources) - { - return new HashSet(resources.Cast().Select(resource => resource.StringId)); - } - - private sealed class GetHookResult - where TResource : class, IIdentifiable - { - public IResourceHookContainer Container { get; } - public RootNode Node { get; } - - public bool Succeeded => Container != null; - - public GetHookResult(IResourceHookContainer container, RootNode node) - { - Container = container; - Node = node; - } - } - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutorFacade.cs b/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutorFacade.cs deleted file mode 100644 index 21c03ab33f..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/ResourceHookExecutorFacade.cs +++ /dev/null @@ -1,138 +0,0 @@ -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Hooks.Internal.Execution; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCore.Hooks.Internal -{ - /// - /// Facade for hooks that invokes callbacks on , which is used when - /// is true. - /// - internal sealed class ResourceHookExecutorFacade : IResourceHookExecutorFacade - { - private readonly IResourceHookExecutor _resourceHookExecutor; - private readonly IResourceFactory _resourceFactory; - - public ResourceHookExecutorFacade(IResourceHookExecutor resourceHookExecutor, IResourceFactory resourceFactory) - { - ArgumentGuard.NotNull(resourceHookExecutor, nameof(resourceHookExecutor)); - ArgumentGuard.NotNull(resourceFactory, nameof(resourceFactory)); - - _resourceHookExecutor = resourceHookExecutor; - _resourceFactory = resourceFactory; - } - - public void BeforeReadSingle(TId id, ResourcePipeline pipeline) - where TResource : class, IIdentifiable - { - var temporaryResource = _resourceFactory.CreateInstance(); - temporaryResource.Id = id; - - _resourceHookExecutor.BeforeRead(pipeline, temporaryResource.StringId); - } - - public void AfterReadSingle(TResource resource, ResourcePipeline pipeline) - where TResource : class, IIdentifiable - { - _resourceHookExecutor.AfterRead(resource.AsList(), pipeline); - } - - public void BeforeReadMany() - where TResource : class, IIdentifiable - { - _resourceHookExecutor.BeforeRead(ResourcePipeline.Get); - } - - public void AfterReadMany(IReadOnlyCollection resources) - where TResource : class, IIdentifiable - { - _resourceHookExecutor.AfterRead(resources, ResourcePipeline.Get); - } - - public void BeforeCreate(TResource resource) - where TResource : class, IIdentifiable - { - _resourceHookExecutor.BeforeCreate(resource.AsList(), ResourcePipeline.Post); - } - - public void AfterCreate(TResource resource) - where TResource : class, IIdentifiable - { - _resourceHookExecutor.AfterCreate(resource.AsList(), ResourcePipeline.Post); - } - - public void BeforeUpdateResource(TResource resource) - where TResource : class, IIdentifiable - { - _resourceHookExecutor.BeforeUpdate(resource.AsList(), ResourcePipeline.Patch); - } - - public void AfterUpdateResource(TResource resource) - where TResource : class, IIdentifiable - { - _resourceHookExecutor.AfterUpdate(resource.AsList(), ResourcePipeline.Patch); - } - - public void BeforeUpdateRelationship(TResource resource) - where TResource : class, IIdentifiable - { - _resourceHookExecutor.BeforeUpdate(resource.AsList(), ResourcePipeline.PatchRelationship); - } - - public void AfterUpdateRelationship(TResource resource) - where TResource : class, IIdentifiable - { - _resourceHookExecutor.AfterUpdate(resource.AsList(), ResourcePipeline.PatchRelationship); - } - - public void BeforeDelete(TId id) - where TResource : class, IIdentifiable - { - var temporaryResource = _resourceFactory.CreateInstance(); - temporaryResource.Id = id; - - _resourceHookExecutor.BeforeDelete(temporaryResource.AsList(), ResourcePipeline.Delete); - } - - public void AfterDelete(TId id) - where TResource : class, IIdentifiable - { - var temporaryResource = _resourceFactory.CreateInstance(); - temporaryResource.Id = id; - - _resourceHookExecutor.AfterDelete(temporaryResource.AsList(), ResourcePipeline.Delete, true); - } - - public void OnReturnSingle(TResource resource, ResourcePipeline pipeline) - where TResource : class, IIdentifiable - { - _resourceHookExecutor.OnReturn(resource.AsList(), pipeline); - } - - public IReadOnlyCollection OnReturnMany(IReadOnlyCollection resources) - where TResource : class, IIdentifiable - { - return _resourceHookExecutor.OnReturn(resources, ResourcePipeline.Get).ToArray(); - } - - public object OnReturnRelationship(object resourceOrResources) - { - if (resourceOrResources is IEnumerable) - { - dynamic resources = resourceOrResources; - return Enumerable.ToArray(_resourceHookExecutor.OnReturn(resources, ResourcePipeline.GetRelationship)); - } - - if (resourceOrResources is IIdentifiable) - { - dynamic resources = ObjectExtensions.AsList((dynamic)resourceOrResources); - return Enumerable.SingleOrDefault(_resourceHookExecutor.OnReturn(resources, ResourcePipeline.GetRelationship)); - } - - return resourceOrResources; - } - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/ChildNode.cs b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/ChildNode.cs deleted file mode 100644 index 7bf2209118..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/ChildNode.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using JsonApiDotNetCore.Resources; -using RightType = System.Type; - -namespace JsonApiDotNetCore.Hooks.Internal.Traversal -{ - internal abstract class ChildNode - { - protected static readonly CollectionConverter CollectionConverter = new CollectionConverter(); - } - - /// - /// Child node in the tree - /// - /// - internal sealed class ChildNode : ChildNode, IResourceNode - where TResource : class, IIdentifiable - { - private readonly IdentifiableComparer _comparer = IdentifiableComparer.Instance; - private readonly RelationshipsFromPreviousLayer _relationshipsFromPreviousLayer; - - /// - public RightType ResourceType { get; } - - /// - public IReadOnlyCollection RelationshipsToNextLayer { get; } - - /// - public IEnumerable UniqueResources - { - get - { - return new HashSet(_relationshipsFromPreviousLayer.SelectMany(relationshipGroup => relationshipGroup.RightResources)); - } - } - - /// - public IRelationshipsFromPreviousLayer RelationshipsFromPreviousLayer => _relationshipsFromPreviousLayer; - - public ChildNode(IReadOnlyCollection nextLayerRelationships, RelationshipsFromPreviousLayer prevLayerRelationships) - { - ResourceType = typeof(TResource); - RelationshipsToNextLayer = nextLayerRelationships; - _relationshipsFromPreviousLayer = prevLayerRelationships; - } - - /// - public void UpdateUnique(IEnumerable updated) - { - List list = updated.Cast().ToList(); - - foreach (RelationshipGroup group in _relationshipsFromPreviousLayer) - { - group.RightResources = new HashSet(group.RightResources.Intersect(list, _comparer).Cast()); - } - } - - /// - /// Reassignment is done according to provided relationships - /// - public void Reassign(IEnumerable updated = null) - { - var unique = (HashSet)UniqueResources; - - foreach (RelationshipGroup group in _relationshipsFromPreviousLayer) - { - RelationshipProxy proxy = group.Proxy; - HashSet leftResources = group.LeftResources; - - Reassign(leftResources, proxy, unique); - } - } - - private void Reassign(IEnumerable leftResources, RelationshipProxy proxy, HashSet unique) - { - foreach (IIdentifiable left in leftResources) - { - object currentValue = proxy.GetValue(left); - - if (currentValue is IEnumerable relationshipCollection) - { - IEnumerable intersection = relationshipCollection.Intersect(unique, _comparer); - IEnumerable typedCollection = CollectionConverter.CopyToTypedCollection(intersection, relationshipCollection.GetType()); - proxy.SetValue(left, typedCollection); - } - else if (currentValue is IIdentifiable relationshipSingle) - { - if (!unique.Intersect(new HashSet - { - relationshipSingle - }, _comparer).Any()) - { - proxy.SetValue(left, null); - } - } - } - } - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/INodeNavigator.cs b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/INodeNavigator.cs deleted file mode 100644 index 1b01751736..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/INodeNavigator.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCore.Hooks.Internal.Traversal -{ - internal interface INodeNavigator - { - /// - /// Creates the next layer - /// - IEnumerable CreateNextLayer(IResourceNode node); - - /// - /// Creates the next layer based on the nodes provided - /// - IEnumerable CreateNextLayer(IEnumerable nodes); - - /// - /// Creates a root node for breadth-first-traversal (BFS). Note that typically, in JsonApiDotNetCore, the root layer will be homogeneous. Also, because - /// it is the first layer, there can be no relationships to previous layers, only to next layers. - /// - /// - /// The root node. - /// - /// - /// Root resources. - /// - /// - /// The 1st type parameter. - /// - RootNode CreateRootNode(IEnumerable rootResources) - where TResource : class, IIdentifiable; - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/IRelationshipGroup.cs b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/IRelationshipGroup.cs deleted file mode 100644 index 3dc9c9a8fa..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/IRelationshipGroup.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace JsonApiDotNetCore.Hooks.Internal.Traversal -{ - internal interface IRelationshipGroup - { - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/IRelationshipsFromPreviousLayer.cs b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/IRelationshipsFromPreviousLayer.cs deleted file mode 100644 index ff8795aaa6..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/IRelationshipsFromPreviousLayer.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Collections; -using System.Collections.Generic; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Hooks.Internal.Traversal -{ - /// - /// A helper class for mapping relationships between a current and previous layer - /// - internal interface IRelationshipsFromPreviousLayer - { - /// - /// Grouped by relationship to the previous layer, gets all the resources of the current layer - /// - /// - /// The right side resources. - /// - IDictionary GetRightResources(); - - /// - /// Grouped by relationship to the previous layer, gets all the resources of the previous layer - /// - /// - /// The right side resources. - /// - IDictionary GetLeftResources(); - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/IResourceNode.cs b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/IResourceNode.cs deleted file mode 100644 index d86fb05431..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/IResourceNode.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Collections; -using System.Collections.Generic; -using RightType = System.Type; - -namespace JsonApiDotNetCore.Hooks.Internal.Traversal -{ - /// - /// This is the interface that nodes need to inherit from - /// - internal interface IResourceNode - { - /// - /// Each node represents the resources of a given type throughout a particular layer. - /// - RightType ResourceType { get; } - - /// - /// The unique set of resources in this node. Note that these are all of the same type. - /// - IEnumerable UniqueResources { get; } - - /// - /// Relationships to the next layer - /// - /// - /// The relationships to next layer. - /// - IReadOnlyCollection RelationshipsToNextLayer { get; } - - /// - /// Relationships to the previous layer - /// - IRelationshipsFromPreviousLayer RelationshipsFromPreviousLayer { get; } - - /// - /// A helper method to assign relationships to the previous layer after firing hooks. Or, in case of the root node, to update the original source - /// enumerable. - /// - void Reassign(IEnumerable source = null); - - /// - /// A helper method to internally update the unique set of resources as a result of a filter action in a hook. - /// - /// Updated. - void UpdateUnique(IEnumerable updated); - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/NodeNavigator.cs b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/NodeNavigator.cs deleted file mode 100644 index ac4435ac7e..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/NodeNavigator.cs +++ /dev/null @@ -1,377 +0,0 @@ -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using RightType = System.Type; -using LeftType = System.Type; - -namespace JsonApiDotNetCore.Hooks.Internal.Traversal -{ - /// - /// A helper class used by the to traverse through resource data structures (trees), allowing for a - /// breadth-first-traversal It creates nodes for each layer. Typically, the first layer is homogeneous (all resources have the same type), and further - /// nodes can be mixed. - /// - internal sealed class NodeNavigator : INodeNavigator - { - private static readonly HooksObjectFactory ObjectFactory = new HooksObjectFactory(); - private static readonly HooksCollectionConverter CollectionConverter = new HooksCollectionConverter(); - - private readonly IdentifiableComparer _comparer = IdentifiableComparer.Instance; - private readonly IResourceGraph _resourceGraph; - private readonly ITargetedFields _targetedFields; - - /// - /// A mapper from to . See the latter for more details. - /// - private readonly Dictionary _relationshipProxies = new Dictionary(); - - /// - /// Keeps track of which resources has already been traversed through, to prevent infinite loops in eg cyclic data structures. - /// - private Dictionary> _processedResources; - - public NodeNavigator(IResourceGraph resourceGraph, ITargetedFields targetedFields) - { - _targetedFields = targetedFields; - _resourceGraph = resourceGraph; - } - - /// - /// Creates a root node for breadth-first-traversal. Note that typically, in JsonApiDotNetCore, the root layer will be homogeneous. Also, because it is - /// the first layer, there can be no relationships to previous layers, only to next layers. - /// - /// - /// The root node. - /// - /// - /// Root resources. - /// - /// - /// The 1st type parameter. - /// - public RootNode CreateRootNode(IEnumerable rootResources) - where TResource : class, IIdentifiable - { - _processedResources = new Dictionary>(); - RegisterRelationshipProxies(typeof(TResource)); - ISet uniqueResources = ProcessResources(rootResources); - IReadOnlyCollection populatedRelationshipsToNextLayer = GetPopulatedRelationships(typeof(TResource), uniqueResources); - - IReadOnlyCollection allRelationshipsFromType = - _relationshipProxies.Select(entry => entry.Value).Where(proxy => proxy.LeftType == typeof(TResource)).ToArray(); - - return new RootNode(uniqueResources, populatedRelationshipsToNextLayer, allRelationshipsFromType); - } - - /// - /// Create the first layer after the root layer (based on the root node) - /// - /// - /// The next layer. - /// - /// - /// Root node. - /// - public IEnumerable CreateNextLayer(IResourceNode rootNode) - { - return CreateNextLayer(rootNode.AsEnumerable()); - } - - /// - /// Create a next layer from any previous layer - /// - /// - /// The next layer. - /// - /// Nodes. - public IEnumerable CreateNextLayer(IEnumerable nodes) - { - // first extract resources by parsing populated relationships in the resources - // of previous layer - (Dictionary> lefts, Dictionary> rights) = ExtractResources(nodes); - - // group them conveniently so we can make ChildNodes of them: - // there might be several relationship attributes in rights dictionary - // that point to the same right type. - IDictionary>>> leftsGrouped = GroupByRightTypeOfRelationship(lefts); - - // convert the groups into child nodes - List nextNodes = leftsGrouped.Select(entry => - { - RightType nextNodeType = entry.Key; - RegisterRelationshipProxies(nextNodeType); - - var populatedRelationships = new List(); - - List relationshipsToPreviousLayer = entry.Value.Select(grouped => - { - RelationshipProxy proxy = grouped.Key; - populatedRelationships.AddRange(GetPopulatedRelationships(nextNodeType, rights[proxy])); - return CreateRelationshipGroupInstance(nextNodeType, proxy, grouped.Value, rights[proxy]); - }).ToList(); - - return CreateNodeInstance(nextNodeType, populatedRelationships.ToArray(), relationshipsToPreviousLayer); - }).ToList(); - - return nextNodes; - } - - /// - /// iterates through the dictionary and groups the values by matching right type of the keys (which are relationship - /// attributes) - /// - private IDictionary>>> GroupByRightTypeOfRelationship( - Dictionary> relationships) - { - return relationships.GroupBy(pair => pair.Key.RightType).ToDictionary(grouping => grouping.Key, grouping => grouping.ToList()); - } - - /// - /// Extracts the resources for the current layer by going through all populated relationships of the (left resources of the previous layer. - /// - private (Dictionary>, Dictionary>) ExtractResources( - IEnumerable leftNodes) - { - // RelationshipAttr_prevLayer->currentLayer => prevLayerResources - var leftResourcesGrouped = new Dictionary>(); - - // RelationshipAttr_prevLayer->currentLayer => currentLayerResources - var rightResourcesGrouped = new Dictionary>(); - - foreach (IResourceNode node in leftNodes) - { - IEnumerable leftResources = node.UniqueResources; - IReadOnlyCollection relationships = node.RelationshipsToNextLayer; - - ExtractLeftResources(leftResources, relationships, rightResourcesGrouped, leftResourcesGrouped); - } - - MethodInfo processResourcesMethod = GetType().GetMethod(nameof(ProcessResources), BindingFlags.NonPublic | BindingFlags.Instance); - - foreach (KeyValuePair> pair in rightResourcesGrouped) - { - RightType type = pair.Key.RightType; - IList list = CollectionConverter.CopyToList(pair.Value, type); - processResourcesMethod!.MakeGenericMethod(type).Invoke(this, ArrayFactory.Create(list)); - } - - return (leftResourcesGrouped, rightResourcesGrouped); - } - - private void ExtractLeftResources(IEnumerable leftResources, IReadOnlyCollection relationships, - Dictionary> rightResourcesGrouped, Dictionary> leftResourcesGrouped) - { - foreach (IIdentifiable leftResource in leftResources) - { - ExtractLeftResource(leftResource, relationships, rightResourcesGrouped, leftResourcesGrouped); - } - } - - private void ExtractLeftResource(IIdentifiable leftResource, IReadOnlyCollection relationships, - Dictionary> rightResourcesGrouped, Dictionary> leftResourcesGrouped) - { - foreach (RelationshipProxy proxy in relationships) - { - object relationshipValue = proxy.GetValue(leftResource); - - // skip this relationship if it's not populated - if (!proxy.IsContextRelation && relationshipValue == null) - { - continue; - } - - ICollection rightResources = CollectionConverter.ExtractResources(relationshipValue); - ISet uniqueRightResources = UniqueInTree(rightResources, proxy.RightType); - - if (proxy.IsContextRelation || uniqueRightResources.Any()) - { - AddToRelationshipGroup(rightResourcesGrouped, proxy, uniqueRightResources); - AddToRelationshipGroup(leftResourcesGrouped, proxy, leftResource.AsEnumerable()); - } - } - } - - /// - /// Get all populated relationships known in the current tree traversal from a left type to any right type - /// - /// - /// The relationships. - /// - private IReadOnlyCollection GetPopulatedRelationships(LeftType leftType, IEnumerable lefts) - { - IEnumerable relationshipsFromLeftToRight = - _relationshipProxies.Select(entry => entry.Value).Where(proxy => proxy.LeftType == leftType); - - return relationshipsFromLeftToRight.Where(proxy => proxy.IsContextRelation || lefts.Any(resource => proxy.GetValue(resource) != null)).ToArray(); - } - - /// - /// Registers the resources as "seen" in the tree traversal, extracts any new s from it. - /// - /// - /// The resources. - /// - /// - /// Incoming resources. - /// - /// - /// The 1st type parameter. - /// - private ISet ProcessResources(IEnumerable incomingResources) - where TResource : class, IIdentifiable - { - RightType type = typeof(TResource); - ISet newResources = UniqueInTree(incomingResources, type); - RegisterProcessedResources(newResources, type); - return newResources; - } - - /// - /// Parses all relationships from to other models in the resource resourceGraphs by constructing RelationshipProxies . - /// - /// - /// The type to parse - /// - private void RegisterRelationshipProxies(RightType type) - { - foreach (RelationshipAttribute attr in _resourceGraph.GetRelationships(type)) - { - if (!attr.CanInclude) - { - continue; - } - - if (!_relationshipProxies.TryGetValue(attr, out _)) - { - RightType rightType = GetRightTypeFromRelationship(attr); - bool isContextRelation = false; - ISet relationshipsToUpdate = _targetedFields.Relationships; - - if (relationshipsToUpdate != null) - { - isContextRelation = relationshipsToUpdate.Contains(attr); - } - - var proxy = new RelationshipProxy(attr, rightType, isContextRelation); - _relationshipProxies[attr] = proxy; - } - } - } - - /// - /// Registers the processed resources in the dictionary grouped by type - /// - /// - /// Resources to register - /// - /// - /// Resource type. - /// - private void RegisterProcessedResources(IEnumerable resources, RightType resourceType) - { - ISet processedResources = GetProcessedResources(resourceType); - processedResources.UnionWith(new HashSet(resources)); - } - - /// - /// Gets the processed resources for a given type, instantiates the collection if new. - /// - /// - /// The processed resources. - /// - /// - /// Resource type. - /// - private ISet GetProcessedResources(RightType resourceType) - { - if (!_processedResources.TryGetValue(resourceType, out HashSet processedResources)) - { - processedResources = new HashSet(); - _processedResources[resourceType] = processedResources; - } - - return processedResources; - } - - /// - /// Using the register of processed resources, determines the unique and new resources with respect to previous iterations. - /// - /// - /// The in tree. - /// - private ISet UniqueInTree(IEnumerable resources, RightType resourceType) - where TResource : class, IIdentifiable - { - IEnumerable newResources = resources.Except(GetProcessedResources(resourceType), _comparer).Cast(); - return new HashSet(newResources); - } - - /// - /// Gets the type from relationship attribute. If the attribute is HasManyThrough, and the through type is identifiable, then the target type is the - /// through type instead of the right type, because hooks might be implemented for the through resource. - /// - /// - /// The target type for traversal - /// - /// - /// Relationship attribute - /// - private RightType GetRightTypeFromRelationship(RelationshipAttribute attr) - { - if (attr is HasManyThroughAttribute throughAttr && throughAttr.ThroughType.IsOrImplementsInterface(typeof(IIdentifiable))) - { - return throughAttr.ThroughType; - } - - return attr.RightType; - } - - private void AddToRelationshipGroup(Dictionary> target, RelationshipProxy proxy, - IEnumerable newResources) - { - if (!target.TryGetValue(proxy, out List resources)) - { - resources = new List(); - target[proxy] = resources; - } - - resources.AddRange(newResources); - } - - /// - /// Reflective helper method to create an instance of ; - /// - private IResourceNode CreateNodeInstance(RightType nodeType, IReadOnlyCollection relationshipsToNext, - IEnumerable relationshipsFromPrev) - { - IRelationshipsFromPreviousLayer prev = CreateRelationshipsFromInstance(nodeType, relationshipsFromPrev); - return (IResourceNode)ObjectFactory.CreateInstanceOfOpenType(typeof(ChildNode<>), nodeType, relationshipsToNext, prev); - } - - /// - /// Reflective helper method to create an instance of ; - /// - private IRelationshipsFromPreviousLayer CreateRelationshipsFromInstance(RightType nodeType, IEnumerable relationshipsFromPrev) - { - List relationshipsFromPrevList = relationshipsFromPrev.ToList(); - IList list = CollectionConverter.CopyToList(relationshipsFromPrevList, relationshipsFromPrevList.First().GetType()); - return (IRelationshipsFromPreviousLayer)ObjectFactory.CreateInstanceOfOpenType(typeof(RelationshipsFromPreviousLayer<>), nodeType, list); - } - - /// - /// Reflective helper method to create an instance of ; - /// - private IRelationshipGroup CreateRelationshipGroupInstance(RightType thisLayerType, RelationshipProxy proxy, List leftResources, - List rightResources) - { - IEnumerable rightResourceSet = CollectionConverter.CopyToHashSet(rightResources, thisLayerType); - - return (IRelationshipGroup)ObjectFactory.CreateInstanceOfOpenType(typeof(RelationshipGroup<>), thisLayerType, proxy, - new HashSet(leftResources), rightResourceSet); - } - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RelationshipGroup.cs b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RelationshipGroup.cs deleted file mode 100644 index 69a947f9f9..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RelationshipGroup.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCore.Hooks.Internal.Traversal -{ - internal sealed class RelationshipGroup : IRelationshipGroup - where TRight : class, IIdentifiable - { - public RelationshipProxy Proxy { get; } - public HashSet LeftResources { get; } - public HashSet RightResources { get; internal set; } - - public RelationshipGroup(RelationshipProxy proxy, HashSet leftResources, HashSet rightResources) - { - Proxy = proxy; - LeftResources = leftResources; - RightResources = rightResources; - } - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RelationshipProxy.cs b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RelationshipProxy.cs deleted file mode 100644 index b9cd252b4e..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RelationshipProxy.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Hooks.Internal.Traversal -{ - /// - /// A class used internally for resource hook execution. Not intended for developer use. A wrapper for RelationshipAttribute with an abstraction layer - /// that works on the getters and setters of relationships. These are different in the case of HasMany vs HasManyThrough, and HasManyThrough. It also - /// depends on if the through type (eg ArticleTags) is identifiable (in which case we will traverse through it and fire hooks for it, if defined) or not - /// (in which case we skip ArticleTags and go directly to Tags. - /// - internal sealed class RelationshipProxy - { - private static readonly HooksCollectionConverter CollectionConverter = new HooksCollectionConverter(); - - private readonly bool _skipThroughType; - - public Type LeftType => Attribute.LeftType; - - /// - /// The target type for this relationship attribute. For HasOne has HasMany this is trivial: just the right-hand side. For HasManyThrough it is either - /// the ThroughProperty (when the through resource is Identifiable) or it is the right-hand side (when the through resource is not identifiable) - /// - public Type RightType { get; } - - public bool IsContextRelation { get; } - - public RelationshipAttribute Attribute { get; } - - public RelationshipProxy(RelationshipAttribute attr, Type relatedType, bool isContextRelation) - { - RightType = relatedType; - Attribute = attr; - IsContextRelation = isContextRelation; - - if (attr is HasManyThroughAttribute throughAttr) - { - _skipThroughType |= RightType != throughAttr.ThroughType; - } - } - - /// - /// Gets the relationship value for a given parent resource. Internally knows how to do this depending on the type of RelationshipAttribute that this - /// RelationshipProxy encapsulates. - /// - /// - /// The relationship value. - /// - /// - /// Parent resource. - /// - public object GetValue(IIdentifiable resource) - { - if (Attribute is HasManyThroughAttribute hasManyThrough) - { - if (!_skipThroughType) - { - return hasManyThrough.ThroughProperty.GetValue(resource); - } - - var collection = new List(); - var throughResources = (IEnumerable)hasManyThrough.ThroughProperty.GetValue(resource); - - if (throughResources == null) - { - return null; - } - - foreach (object throughResource in throughResources) - { - var rightResource = (IIdentifiable)hasManyThrough.RightProperty.GetValue(throughResource); - - if (rightResource == null) - { - continue; - } - - collection.Add(rightResource); - } - - return collection; - } - - return Attribute.GetValue(resource); - } - - /// - /// Set the relationship value for a given parent resource. Internally knows how to do this depending on the type of RelationshipAttribute that this - /// RelationshipProxy encapsulates. - /// - /// - /// Parent resource. - /// - /// - /// The relationship value. - /// - public void SetValue(IIdentifiable resource, object value) - { - if (Attribute is HasManyThroughAttribute hasManyThrough) - { - if (!_skipThroughType) - { - hasManyThrough.ThroughProperty.SetValue(resource, value); - return; - } - - var throughResources = (IEnumerable)hasManyThrough.ThroughProperty.GetValue(resource); - - var filteredList = new List(); - - IList rightResources = CollectionConverter.CopyToList((IEnumerable)value, RightType); - - foreach (object throughResource in throughResources ?? Array.Empty()) - { - if (rightResources.Contains(hasManyThrough.RightProperty.GetValue(throughResource))) - { - filteredList.Add(throughResource); - } - } - - IEnumerable collectionValue = CollectionConverter.CopyToTypedCollection(filteredList, hasManyThrough.ThroughProperty.PropertyType); - hasManyThrough.ThroughProperty.SetValue(resource, collectionValue); - return; - } - - Attribute.SetValue(resource, value); - } - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RelationshipsFromPreviousLayer.cs b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RelationshipsFromPreviousLayer.cs deleted file mode 100644 index 9a91205628..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RelationshipsFromPreviousLayer.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Hooks.Internal.Traversal -{ - internal sealed class RelationshipsFromPreviousLayer : IRelationshipsFromPreviousLayer, IEnumerable> - where TRightResource : class, IIdentifiable - { - private readonly IEnumerable> _collection; - - public RelationshipsFromPreviousLayer(IEnumerable> collection) - { - _collection = collection; - } - - /// - public IDictionary GetRightResources() - { - return _collection.ToDictionary(rg => rg.Proxy.Attribute, rg => (IEnumerable)rg.RightResources); - } - - /// - public IDictionary GetLeftResources() - { - return _collection.ToDictionary(rg => rg.Proxy.Attribute, rg => (IEnumerable)rg.LeftResources); - } - - public IEnumerator> GetEnumerator() - { - return _collection.Cast>().GetEnumerator(); - } - - IEnumerator IEnumerable.GetEnumerator() - { - return GetEnumerator(); - } - } -} diff --git a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RootNode.cs b/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RootNode.cs deleted file mode 100644 index a4453dbace..0000000000 --- a/src/JsonApiDotNetCore/Hooks/Internal/Traversal/RootNode.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Hooks.Internal.Traversal -{ - /// - /// The root node class of the breadth-first-traversal of resource data structures as performed by the - /// - internal sealed class RootNode : IResourceNode - where TResource : class, IIdentifiable - { - private readonly IdentifiableComparer _comparer = IdentifiableComparer.Instance; - private readonly IReadOnlyCollection _allRelationshipsToNextLayer; - private HashSet _uniqueResources; - public Type ResourceType { get; } - public IEnumerable UniqueResources => _uniqueResources; - public IReadOnlyCollection RelationshipsToNextLayer { get; } - - /// - /// The root node does not have a parent layer and therefore does not have any relationships to any previous layer - /// - public IRelationshipsFromPreviousLayer RelationshipsFromPreviousLayer => null; - - public RootNode(IEnumerable uniqueResources, IReadOnlyCollection populatedRelationships, - IReadOnlyCollection allRelationships) - { - ResourceType = typeof(TResource); - _uniqueResources = new HashSet(uniqueResources); - RelationshipsToNextLayer = populatedRelationships; - _allRelationshipsToNextLayer = allRelationships; - } - - public IDictionary> LeftsToNextLayerByRelationships() - { - return _allRelationshipsToNextLayer.GroupBy(proxy => proxy.RightType).ToDictionary(grouping => grouping.Key, - grouping => grouping.ToDictionary(proxy => proxy.Attribute, _ => UniqueResources)); - } - - /// - /// The current layer resources grouped by affected relationship to the next layer - /// - public IDictionary LeftsToNextLayer() - { - return RelationshipsToNextLayer.ToDictionary(proxy => proxy.Attribute, _ => UniqueResources); - } - - /// - /// Update the internal list of affected resources. - /// - /// Updated. - public void UpdateUnique(IEnumerable updated) - { - List list = updated.Cast().ToList(); - IEnumerable intersected = _uniqueResources.Intersect(list, _comparer).Cast(); - _uniqueResources = new HashSet(intersected); - } - - public void Reassign(IEnumerable source = null) - { - IEnumerable ids = _uniqueResources.Select(ue => ue.StringId); - - if (source is HashSet hashSet) - { - hashSet.RemoveWhere(se => !ids.Contains(se.StringId)); - } - else if (source is List list) - { - list.RemoveAll(se => !ids.Contains(se.StringId)); - } - else if (source != null) - { - throw new NotSupportedException($"Unsupported collection type '{source.GetType()}'."); - } - } - } -} diff --git a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj index 8021a03037..d36600e878 100644 --- a/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj +++ b/src/JsonApiDotNetCore/JsonApiDotNetCore.csproj @@ -1,33 +1,45 @@ - 4.2.0 - $(NetCoreAppVersion) + net8.0 + true true + + - jsonapidotnetcore;jsonapi;json:api;dotnet;asp.net - A framework for building JSON:API compliant REST APIs using .NET Core 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. + jsonapidotnetcore;jsonapi;json:api;dotnet;asp.net;rest;web-api + 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. + package-icon.png + PackageReadme.md true - true embedded + + + + + + + + + + - - - - - - - - + + + + + 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 f21dcad2b4..440d3e69ea 100644 --- a/src/JsonApiDotNetCore/Middleware/AsyncConvertEmptyActionResultFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/AsyncConvertEmptyActionResultFilter.cs @@ -1,34 +1,32 @@ -using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Infrastructure; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +/// +public sealed class AsyncConvertEmptyActionResultFilter : IAsyncConvertEmptyActionResultFilter { /// - public sealed class AsyncConvertEmptyActionResultFilter : IAsyncConvertEmptyActionResultFilter + public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next) { - /// - 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()) + if (context.HttpContext.IsJsonApiRequest()) + { + if (context.Result is not ObjectResult objectResult || objectResult.Value == null) { - if (!(context.Result is ObjectResult objectResult) || objectResult.Value == null) + if (context.Result is IStatusCodeActionResult statusCodeResult) { - if (context.Result is IStatusCodeActionResult statusCodeResult) + context.Result = new ObjectResult(null) { - context.Result = new ObjectResult(null) - { - StatusCode = statusCodeResult.StatusCode - }; - } + StatusCode = statusCodeResult.StatusCode + }; } } - - await next(); } + + await next(); } } diff --git a/src/JsonApiDotNetCore/Middleware/AsyncJsonApiExceptionFilter.cs b/src/JsonApiDotNetCore/Middleware/AsyncJsonApiExceptionFilter.cs index 54f9bfaf9f..915747de8b 100644 --- a/src/JsonApiDotNetCore/Middleware/AsyncJsonApiExceptionFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/AsyncJsonApiExceptionFilter.cs @@ -1,40 +1,38 @@ -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +/// +[PublicAPI] +public sealed class AsyncJsonApiExceptionFilter : IAsyncJsonApiExceptionFilter { - /// - [PublicAPI] - public class AsyncJsonApiExceptionFilter : IAsyncJsonApiExceptionFilter + private readonly IExceptionHandler _exceptionHandler; + + public AsyncJsonApiExceptionFilter(IExceptionHandler exceptionHandler) { - private readonly IExceptionHandler _exceptionHandler; + ArgumentNullException.ThrowIfNull(exceptionHandler); - public AsyncJsonApiExceptionFilter(IExceptionHandler exceptionHandler) - { - ArgumentGuard.NotNull(exceptionHandler, nameof(exceptionHandler)); + _exceptionHandler = exceptionHandler; + } - _exceptionHandler = exceptionHandler; - } + /// + public Task OnExceptionAsync(ExceptionContext context) + { + ArgumentNullException.ThrowIfNull(context); - /// - public Task OnExceptionAsync(ExceptionContext context) + if (context.HttpContext.IsJsonApiRequest()) { - ArgumentGuard.NotNull(context, nameof(context)); + IReadOnlyList errors = _exceptionHandler.HandleException(context.Exception); - if (context.HttpContext.IsJsonApiRequest()) + context.Result = new ObjectResult(errors) { - ErrorDocument errorDocument = _exceptionHandler.HandleException(context.Exception); - - context.Result = new ObjectResult(errorDocument) - { - StatusCode = (int)errorDocument.GetErrorStatusCode() - }; - } - - return Task.CompletedTask; + StatusCode = (int)ErrorObject.GetResponseStatusCode(errors) + }; } + + return Task.CompletedTask; } } diff --git a/src/JsonApiDotNetCore/Middleware/AsyncQueryStringActionFilter.cs b/src/JsonApiDotNetCore/Middleware/AsyncQueryStringActionFilter.cs index 7a72874e5b..e832b4693a 100644 --- a/src/JsonApiDotNetCore/Middleware/AsyncQueryStringActionFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/AsyncQueryStringActionFilter.cs @@ -1,36 +1,34 @@ using System.Reflection; -using System.Threading.Tasks; using JsonApiDotNetCore.Controllers.Annotations; using JsonApiDotNetCore.QueryStrings; using Microsoft.AspNetCore.Mvc.Filters; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +/// +public sealed class AsyncQueryStringActionFilter : IAsyncQueryStringActionFilter { - /// - public sealed class AsyncQueryStringActionFilter : IAsyncQueryStringActionFilter + private readonly IQueryStringReader _queryStringReader; + + public AsyncQueryStringActionFilter(IQueryStringReader queryStringReader) { - private readonly IQueryStringReader _queryStringReader; + ArgumentNullException.ThrowIfNull(queryStringReader); - public AsyncQueryStringActionFilter(IQueryStringReader queryStringReader) - { - ArgumentGuard.NotNull(queryStringReader, nameof(queryStringReader)); + _queryStringReader = queryStringReader; + } - _queryStringReader = queryStringReader; - } + /// + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(next); - /// - public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + if (context.HttpContext.IsJsonApiRequest()) { - ArgumentGuard.NotNull(context, nameof(context)); - ArgumentGuard.NotNull(next, nameof(next)); - - if (context.HttpContext.IsJsonApiRequest()) - { - var disableQueryStringAttribute = context.Controller.GetType().GetCustomAttribute(); - _queryStringReader.ReadAll(disableQueryStringAttribute); - } - - await next(); + var disableQueryStringAttribute = context.Controller.GetType().GetCustomAttribute(true); + _queryStringReader.ReadAll(disableQueryStringAttribute); } + + await next(); } } diff --git a/src/JsonApiDotNetCore/Middleware/EndpointKind.cs b/src/JsonApiDotNetCore/Middleware/EndpointKind.cs index 6123aa09f9..b97d7735f3 100644 --- a/src/JsonApiDotNetCore/Middleware/EndpointKind.cs +++ b/src/JsonApiDotNetCore/Middleware/EndpointKind.cs @@ -1,25 +1,24 @@ -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +public enum EndpointKind { - public enum EndpointKind - { - /// - /// A top-level resource request, for example: "/blogs" or "/blogs/123" - /// - Primary, + /// + /// A top-level resource request, for example: "/blogs" or "/blogs/123" + /// + Primary, - /// - /// A nested resource request, for example: "/blogs/123/author" or "/author/123/articles" - /// - Secondary, + /// + /// A nested resource request, for example: "/blogs/123/author" or "/author/123/articles" + /// + Secondary, - /// - /// A relationship request, for example: "/blogs/123/relationships/author" or "/author/123/relationships/articles" - /// - Relationship, + /// + /// A relationship request, for example: "/blogs/123/relationships/author" or "/author/123/relationships/articles" + /// + Relationship, - /// - /// A request to an atomic:operations endpoint. - /// - AtomicOperations - } + /// + /// A request to an atomic:operations endpoint. + /// + AtomicOperations } diff --git a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs index 2006ad62de..44e1b384ef 100644 --- a/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs +++ b/src/JsonApiDotNetCore/Middleware/ExceptionHandler.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using System.Diagnostics; using System.Net; using JetBrains.Annotations; @@ -8,94 +6,113 @@ using JsonApiDotNetCore.Serialization.Objects; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +/// +[PublicAPI] +public partial class ExceptionHandler : IExceptionHandler { - /// - [PublicAPI] - public class ExceptionHandler : IExceptionHandler + private readonly IJsonApiOptions _options; + private readonly ILogger _logger; + + public ExceptionHandler(ILoggerFactory loggerFactory, IJsonApiOptions options) { - private readonly IJsonApiOptions _options; - private readonly ILogger _logger; + ArgumentNullException.ThrowIfNull(loggerFactory); + ArgumentNullException.ThrowIfNull(options); - public ExceptionHandler(ILoggerFactory loggerFactory, IJsonApiOptions options) - { - ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); - ArgumentGuard.NotNull(options, nameof(options)); + _options = options; + _logger = loggerFactory.CreateLogger(); + } - _options = options; - _logger = loggerFactory.CreateLogger(); - } + public IReadOnlyList HandleException(Exception exception) + { + ArgumentNullException.ThrowIfNull(exception); - public ErrorDocument HandleException(Exception exception) - { - ArgumentGuard.NotNull(exception, nameof(exception)); + Exception demystified = exception.Demystify(); - Exception demystified = exception.Demystify(); + LogException(demystified); - LogException(demystified); + return CreateErrorResponse(demystified); + } - return CreateErrorDocument(demystified); - } + private void LogException(Exception exception) + { + LogLevel level = GetLogLevel(exception); + string message = GetLogMessage(exception); - private void LogException(Exception exception) - { - LogLevel level = GetLogLevel(exception); - string message = GetLogMessage(exception); + LogException(level, exception, message); + } - _logger.Log(level, exception, message); - } + protected virtual LogLevel GetLogLevel(Exception exception) + { + ArgumentNullException.ThrowIfNull(exception); - protected virtual LogLevel GetLogLevel(Exception exception) + if (exception is OperationCanceledException) { - ArgumentGuard.NotNull(exception, nameof(exception)); + return LogLevel.None; + } - if (exception is OperationCanceledException) - { - return LogLevel.None; - } + if (exception is JsonApiException and not FailedOperationException) + { + return LogLevel.Information; + } - if (exception is JsonApiException) - { - return LogLevel.Information; - } + return LogLevel.Error; + } - return LogLevel.Error; - } + protected virtual string GetLogMessage(Exception exception) + { + ArgumentNullException.ThrowIfNull(exception); - protected virtual string GetLogMessage(Exception exception) - { - ArgumentGuard.NotNull(exception, nameof(exception)); + return exception is JsonApiException jsonApiException ? jsonApiException.GetSummary() : exception.Message; + } - return exception.Message; - } + protected virtual IReadOnlyList CreateErrorResponse(Exception exception) + { + ArgumentNullException.ThrowIfNull(exception); - protected virtual ErrorDocument CreateErrorDocument(Exception exception) + IReadOnlyList errors = exception switch { - ArgumentGuard.NotNull(exception, nameof(exception)); - - IReadOnlyList errors = exception is JsonApiException jsonApiException ? jsonApiException.Errors : - exception is OperationCanceledException ? new Error((HttpStatusCode)499) + JsonApiException jsonApiException => jsonApiException.Errors, + OperationCanceledException => new[] + { + new ErrorObject((HttpStatusCode)499) { Title = "Request execution was canceled." - }.AsArray() : new Error(HttpStatusCode.InternalServerError) + } + }.AsReadOnly(), + _ => new[] + { + new ErrorObject(HttpStatusCode.InternalServerError) { Title = "An unhandled error occurred while processing this request.", Detail = exception.Message - }.AsArray(); + } + }.AsReadOnly() + }; - foreach (Error error in errors) - { - ApplyOptions(error, exception); - } - - return new ErrorDocument(errors); + if (_options.IncludeExceptionStackTraceInErrors && exception is not InvalidModelStateException) + { + IncludeStackTraces(exception, errors); } - private void ApplyOptions(Error error, Exception exception) - { - Exception resultException = exception is InvalidModelStateException ? null : exception; + return errors; + } + + private void IncludeStackTraces(Exception exception, IReadOnlyList errors) + { + string[] stackTraceLines = exception.ToString().Split(Environment.NewLine); - error.Meta.IncludeExceptionStackTrace(_options.IncludeExceptionStackTraceInErrors ? resultException : null); + if (stackTraceLines.Length > 0) + { + foreach (ErrorObject error in errors) + { + error.Meta ??= new Dictionary(); + error.Meta["StackTrace"] = stackTraceLines; + } } } + + [LoggerMessage(Message = "{Message}")] + private partial void LogException(LogLevel level, Exception exception, string message); } diff --git a/src/JsonApiDotNetCore/Middleware/FixedQueryFeature.cs b/src/JsonApiDotNetCore/Middleware/FixedQueryFeature.cs deleted file mode 100644 index 9ce2da0489..0000000000 --- a/src/JsonApiDotNetCore/Middleware/FixedQueryFeature.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System; -using System.Collections.Generic; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Features; -using Microsoft.Extensions.Primitives; - -namespace JsonApiDotNetCore.Middleware -{ - /// - /// Replacement implementation for the ASP.NET built-in , to workaround bug https://github.com/dotnet/aspnetcore/issues/33394. - /// This is identical to the built-in version, except it calls . - /// - internal sealed class FixedQueryFeature : IQueryFeature - { - // Lambda hoisted to static readonly field to improve inlining https://github.com/dotnet/roslyn/issues/13624 - private static readonly Func NullRequestFeature = _ => null; - - private FeatureReferences _features; - - private string _original; - private IQueryCollection _parsedValues; - - private IHttpRequestFeature HttpRequestFeature => _features.Fetch(ref _features.Cache, NullRequestFeature); - - /// - public IQueryCollection Query - { - get - { - if (_features.Collection == null) - { - return _parsedValues ??= QueryCollection.Empty; - } - - string current = HttpRequestFeature.QueryString; - - if (_parsedValues == null || !string.Equals(_original, current, StringComparison.Ordinal)) - { - _original = current; - - Dictionary result = FixedQueryHelpers.ParseNullableQuery(current); - - _parsedValues = result == null ? QueryCollection.Empty : new QueryCollection(result); - } - - return _parsedValues; - } - set - { - _parsedValues = value; - - if (_features.Collection != null) - { - if (value == null) - { - _original = string.Empty; - HttpRequestFeature.QueryString = string.Empty; - } - else - { - _original = QueryString.Create(_parsedValues).ToString(); - HttpRequestFeature.QueryString = _original; - } - } - } - } - - /// - /// Initializes a new instance of . - /// - /// - /// The to initialize. - /// - public FixedQueryFeature(IFeatureCollection features) - { - ArgumentGuard.NotNull(features, nameof(features)); - - _features.Initalize(features); - } - } -} diff --git a/src/JsonApiDotNetCore/Middleware/FixedQueryHelpers.cs b/src/JsonApiDotNetCore/Middleware/FixedQueryHelpers.cs deleted file mode 100644 index 621aca493d..0000000000 --- a/src/JsonApiDotNetCore/Middleware/FixedQueryHelpers.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System; -using System.Collections.Generic; -using Microsoft.AspNetCore.WebUtilities; -using Microsoft.Extensions.Primitives; - -#pragma warning disable AV1008 // Class should not be static -#pragma warning disable AV1708 // Type name contains term that should be avoided -#pragma warning disable AV1130 // Return type in method signature should be a collection interface instead of a concrete type -#pragma warning disable AV1532 // Loop statement contains nested loop - -namespace JsonApiDotNetCore.Middleware -{ - /// - /// Replacement implementation for the ASP.NET built-in , to workaround bug https://github.com/dotnet/aspnetcore/issues/33394. - /// This is identical to the built-in version, except it properly un-escapes query string keys without a value. - /// - internal static class FixedQueryHelpers - { - /// - /// Parse a query string into its component key and value parts. - /// - /// - /// The raw query string value, with or without the leading '?'. - /// - /// - /// A collection of parsed keys and values, null if there are no entries. - /// - public static Dictionary ParseNullableQuery(string queryString) - { - var accumulator = new KeyValueAccumulator(); - - if (string.IsNullOrEmpty(queryString) || queryString == "?") - { - return null; - } - - int scanIndex = 0; - - if (queryString[0] == '?') - { - scanIndex = 1; - } - - int textLength = queryString.Length; - int equalIndex = queryString.IndexOf('='); - - if (equalIndex == -1) - { - equalIndex = textLength; - } - - while (scanIndex < textLength) - { - int delimiterIndex = queryString.IndexOf('&', scanIndex); - - if (delimiterIndex == -1) - { - delimiterIndex = textLength; - } - - if (equalIndex < delimiterIndex) - { - while (scanIndex != equalIndex && char.IsWhiteSpace(queryString[scanIndex])) - { - ++scanIndex; - } - - string name = queryString.Substring(scanIndex, equalIndex - scanIndex); - string value = queryString.Substring(equalIndex + 1, delimiterIndex - equalIndex - 1); - accumulator.Append(Uri.UnescapeDataString(name.Replace('+', ' ')), Uri.UnescapeDataString(value.Replace('+', ' '))); - equalIndex = queryString.IndexOf('=', delimiterIndex); - - if (equalIndex == -1) - { - equalIndex = textLength; - } - } - else - { - if (delimiterIndex > scanIndex) - { - // original code: - // accumulator.Append(queryString.Substring(scanIndex, delimiterIndex - scanIndex), string.Empty); - - // replacement: - string name = queryString.Substring(scanIndex, delimiterIndex - scanIndex); - accumulator.Append(Uri.UnescapeDataString(name.Replace('+', ' ')), string.Empty); - } - } - - scanIndex = delimiterIndex + 1; - } - - if (!accumulator.HasValues) - { - return null; - } - - return accumulator.GetResults(); - } - } -} diff --git a/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs b/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs index c860204c52..05ddc69a6b 100644 --- a/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs +++ b/src/JsonApiDotNetCore/Middleware/HeaderConstants.cs @@ -2,12 +2,17 @@ #pragma warning disable AV1008 // Class should not be static -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +[PublicAPI] +public static class HeaderConstants { - [PublicAPI] - public static class HeaderConstants - { - 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.Default)}.ToString() instead.")] + public const string MediaType = "application/vnd.api+json"; + + [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 b77cf79a2a..85de6e92fb 100644 --- a/src/JsonApiDotNetCore/Middleware/HttpContextExtensions.cs +++ b/src/JsonApiDotNetCore/Middleware/HttpContextExtensions.cs @@ -1,29 +1,28 @@ using JetBrains.Annotations; using Microsoft.AspNetCore.Http; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +[PublicAPI] +public static class HttpContextExtensions { - [PublicAPI] - public static class HttpContextExtensions - { - private const string IsJsonApiRequestKey = "JsonApiDotNetCore_IsJsonApiRequest"; + private const string IsJsonApiRequestKey = "JsonApiDotNetCore_IsJsonApiRequest"; - /// - /// Indicates whether the currently executing HTTP request is being handled by JsonApiDotNetCore. - /// - public static bool IsJsonApiRequest(this HttpContext httpContext) - { - ArgumentGuard.NotNull(httpContext, nameof(httpContext)); + /// + /// Indicates whether the currently executing HTTP request is being handled by JsonApiDotNetCore. + /// + public static bool IsJsonApiRequest(this HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext); - string value = httpContext.Items[IsJsonApiRequestKey] as string; - return value == bool.TrueString; - } + string? value = httpContext.Items[IsJsonApiRequestKey] as string; + return value == bool.TrueString; + } - internal static void RegisterJsonApiRequest(this HttpContext httpContext) - { - ArgumentGuard.NotNull(httpContext, nameof(httpContext)); + internal static void RegisterJsonApiRequest(this HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext); - httpContext.Items[IsJsonApiRequestKey] = bool.TrueString; - } + 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 8e0f8b394e..3116b45d40 100644 --- a/src/JsonApiDotNetCore/Middleware/IAsyncConvertEmptyActionResultFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/IAsyncConvertEmptyActionResultFilter.cs @@ -1,20 +1,17 @@ using JetBrains.Annotations; using Microsoft.AspNetCore.Mvc.Filters; -namespace JsonApiDotNetCore.Middleware -{ - /// - /// Converts action result without parameters into action result with null parameter. - /// - /// return NotFound(null) - /// ]]> - /// - /// This ensures our formatter is invoked, where we'll build a JSON:API compliant response. For details, see: - /// https://github.com/dotnet/aspnetcore/issues/16969 - /// - [PublicAPI] - public interface IAsyncConvertEmptyActionResultFilter : IAsyncAlwaysRunResultFilter - { - } -} +namespace JsonApiDotNetCore.Middleware; + +/// +/// Converts action result without parameters into action result with null parameter. +/// +/// return NotFound(null) +/// ]]> +/// +/// This ensures our formatter is invoked, where we'll build a JSON:API compliant response. For details, see: +/// https://github.com/dotnet/aspnetcore/issues/16969 +/// +[PublicAPI] +public interface IAsyncConvertEmptyActionResultFilter : IAsyncAlwaysRunResultFilter; diff --git a/src/JsonApiDotNetCore/Middleware/IAsyncJsonApiExceptionFilter.cs b/src/JsonApiDotNetCore/Middleware/IAsyncJsonApiExceptionFilter.cs index 7ae6981c77..1fc4e136af 100644 --- a/src/JsonApiDotNetCore/Middleware/IAsyncJsonApiExceptionFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/IAsyncJsonApiExceptionFilter.cs @@ -1,13 +1,10 @@ using JetBrains.Annotations; using Microsoft.AspNetCore.Mvc.Filters; -namespace JsonApiDotNetCore.Middleware -{ - /// - /// Application-wide exception filter that invokes for JSON:API requests. - /// - [PublicAPI] - public interface IAsyncJsonApiExceptionFilter : IAsyncExceptionFilter - { - } -} +namespace JsonApiDotNetCore.Middleware; + +/// +/// Application-wide exception filter that invokes for JSON:API requests. +/// +[PublicAPI] +public interface IAsyncJsonApiExceptionFilter : IAsyncExceptionFilter; diff --git a/src/JsonApiDotNetCore/Middleware/IAsyncQueryStringActionFilter.cs b/src/JsonApiDotNetCore/Middleware/IAsyncQueryStringActionFilter.cs index 4ad18fcf35..d3df469f64 100644 --- a/src/JsonApiDotNetCore/Middleware/IAsyncQueryStringActionFilter.cs +++ b/src/JsonApiDotNetCore/Middleware/IAsyncQueryStringActionFilter.cs @@ -1,13 +1,10 @@ using JetBrains.Annotations; using Microsoft.AspNetCore.Mvc.Filters; -namespace JsonApiDotNetCore.Middleware -{ - /// - /// Application-wide entry point for processing JSON:API request query strings. - /// - [PublicAPI] - public interface IAsyncQueryStringActionFilter : IAsyncActionFilter - { - } -} +namespace JsonApiDotNetCore.Middleware; + +/// +/// Application-wide entry point for processing JSON:API request query strings. +/// +[PublicAPI] +public interface IAsyncQueryStringActionFilter : IAsyncActionFilter; diff --git a/src/JsonApiDotNetCore/Middleware/IControllerResourceMapping.cs b/src/JsonApiDotNetCore/Middleware/IControllerResourceMapping.cs index 4290b3b771..dc47971808 100644 --- a/src/JsonApiDotNetCore/Middleware/IControllerResourceMapping.cs +++ b/src/JsonApiDotNetCore/Middleware/IControllerResourceMapping.cs @@ -1,20 +1,19 @@ -using System; +using JsonApiDotNetCore.Configuration; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +/// +/// Registry of which resource type is associated with which controller. +/// +public interface IControllerResourceMapping { /// - /// Registry of which resource type is associated with which controller. + /// Gets the associated resource type for the provided controller type. /// - public interface IControllerResourceMapping - { - /// - /// Gets the associated resource type for the provided controller type. - /// - Type GetResourceTypeForController(Type controllerType); + ResourceType? GetResourceTypeForController(Type? controllerType); - /// - /// Gets the associated controller name for the provided resource type. - /// - string GetControllerNameForResourceType(Type resourceType); - } + /// + /// Gets the associated controller name for the provided resource type. + /// + string? GetControllerNameForResourceType(ResourceType? resourceType); } diff --git a/src/JsonApiDotNetCore/Middleware/IExceptionHandler.cs b/src/JsonApiDotNetCore/Middleware/IExceptionHandler.cs index 2521794c08..3b3f5307be 100644 --- a/src/JsonApiDotNetCore/Middleware/IExceptionHandler.cs +++ b/src/JsonApiDotNetCore/Middleware/IExceptionHandler.cs @@ -1,13 +1,11 @@ -using System; using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +/// +/// Central place to handle all exceptions, such as log them and translate into error response. +/// +public interface IExceptionHandler { - /// - /// Central place to handle all exceptions. Log them and translate into Error response. - /// - public interface IExceptionHandler - { - ErrorDocument HandleException(Exception exception); - } + IReadOnlyList HandleException(Exception exception); } 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 268c0a2697..7879530650 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiInputFormatter.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiInputFormatter.cs @@ -1,13 +1,10 @@ using JetBrains.Annotations; using Microsoft.AspNetCore.Mvc.Formatters; -namespace JsonApiDotNetCore.Middleware -{ - /// - /// Application-wide entry point for reading JSON:API request bodies. - /// - [PublicAPI] - public interface IJsonApiInputFormatter : IInputFormatter - { - } -} +namespace JsonApiDotNetCore.Middleware; + +/// +/// Application-wide entry point for reading JSON:API request bodies. +/// +[PublicAPI] +public interface IJsonApiInputFormatter : IInputFormatter; diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiOutputFormatter.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiOutputFormatter.cs index 725accb03f..ff285e26e7 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiOutputFormatter.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiOutputFormatter.cs @@ -1,13 +1,10 @@ using JetBrains.Annotations; using Microsoft.AspNetCore.Mvc.Formatters; -namespace JsonApiDotNetCore.Middleware -{ - /// - /// Application-wide entry point for writing JSON:API response bodies. - /// - [PublicAPI] - public interface IJsonApiOutputFormatter : IOutputFormatter - { - } -} +namespace JsonApiDotNetCore.Middleware; + +/// +/// Application-wide entry point for writing JSON:API response bodies. +/// +[PublicAPI] +public interface IJsonApiOutputFormatter : IOutputFormatter; diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs index 081d398b39..959c89d66f 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs @@ -1,76 +1,72 @@ -using System; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +/// +/// Metadata associated with the JSON:API request that is currently being processed. +/// +public interface IJsonApiRequest { /// - /// Metadata associated with the JSON:API request that is currently being processed. + /// Routing information, based on the path of the request URL. /// - public interface IJsonApiRequest - { - /// - /// Routing information, based on the path of the request URL. - /// - public EndpointKind Kind { get; } + public EndpointKind Kind { get; } - /// - /// The request URL prefix. This may be an absolute or relative path, depending on . - /// - /// - /// - /// - [Obsolete("This value is calculated for backwards compatibility, but it is no longer used and will be removed in a future version.")] - string BasePath { get; } + /// + /// The ID of the primary resource for this request. This would be null in "/blogs", "123" in "/blogs/123" or "/blogs/123/author". This is + /// null before and after processing operations in an atomic:operations request. + /// + string? PrimaryId { get; } - /// - /// The ID of the primary (top-level) resource for this request. This would be null in "/blogs", "123" in "/blogs/123" or "/blogs/123/author". - /// - string PrimaryId { get; } + /// + /// The primary resource type for this request. This would be "blogs" in "/blogs", "/blogs/123" or "/blogs/123/author". This is null before and + /// after processing operations in an atomic:operations request. + /// + ResourceType? PrimaryResourceType { get; } - /// - /// The primary (top-level) resource for this request. This would be "blogs" in "/blogs", "/blogs/123" or "/blogs/123/author". - /// - ResourceContext PrimaryResource { get; } + /// + /// The secondary resource type for this request. This would be null in "/blogs", "/blogs/123" and "/blogs/123/unknownResource" or "people" in + /// "/blogs/123/author" and "/blogs/123/relationships/author". This is null before and after processing operations in an atomic:operations + /// request. + /// + ResourceType? SecondaryResourceType { get; } - /// - /// The secondary (nested) resource for this request. This would be null in "/blogs", "/blogs/123" and "/blogs/123/unknownResource" or "people" in - /// "/blogs/123/author" and "/blogs/123/relationships/author". - /// - ResourceContext SecondaryResource { get; } + /// + /// The relationship for this request. This would be null in "/blogs", "/blogs/123" and "/blogs/123/unknownResource" or "author" in + /// "/blogs/123/author" and "/blogs/123/relationships/author". This is null before and after processing operations in an atomic:operations + /// request. + /// + RelationshipAttribute? Relationship { get; } - /// - /// The relationship for this nested request. This would be null in "/blogs", "/blogs/123" and "/blogs/123/unknownResource" or "author" in - /// "/blogs/123/author" and "/blogs/123/relationships/author". - /// - RelationshipAttribute Relationship { get; } + /// + /// Indicates whether this request targets a single resource or a collection of resources. + /// + bool IsCollection { get; } - /// - /// Indicates whether this request targets a single resource or a collection of resources. - /// - bool IsCollection { get; } + /// + /// Indicates whether this request targets only fetching of data (resources and relationships), as opposed to applying changes. + /// + bool IsReadOnly { get; } - /// - /// Indicates whether this request targets only fetching of data (such as resources and relationships). - /// - bool IsReadOnly { get; } + /// + /// In case of a non-readonly request, this indicates the kind of write operation currently being processed. This is null when processing a + /// read-only operation, and before and after processing operations in an atomic:operations request. + /// + WriteOperationKind? WriteOperation { get; } - /// - /// In case of an atomic:operations request, this indicates the kind of operation currently being processed. - /// - OperationKind? OperationKind { get; } + /// + /// In case of an atomic:operations request, identifies the overarching transaction. + /// + string? TransactionId { get; } - /// - /// In case of an atomic:operations request, identifies the overarching transaction. - /// - 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. - /// - void CopyFrom(IJsonApiRequest other); - } + /// + /// Performs a shallow copy. + /// + void CopyFrom(IJsonApiRequest other); } diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiRoutingConvention.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiRoutingConvention.cs index d5db22efa6..a83156f33f 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiRoutingConvention.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiRoutingConvention.cs @@ -1,13 +1,10 @@ using JetBrains.Annotations; using Microsoft.AspNetCore.Mvc.ApplicationModels; -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 - { - } -} +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; 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 fc5a1e2230..008d5fd6dd 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs @@ -1,28 +1,29 @@ -using System.Threading.Tasks; -using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Serialization.Request; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Extensions.DependencyInjection; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +/// +public sealed class JsonApiInputFormatter : IJsonApiInputFormatter { /// - public sealed class JsonApiInputFormatter : IJsonApiInputFormatter + public bool CanRead(InputFormatterContext context) + { + ArgumentNullException.ThrowIfNull(context); + + return context.HttpContext.IsJsonApiRequest(); + } + + /// + public async Task ReadAsync(InputFormatterContext context) { - /// - public bool CanRead(InputFormatterContext context) - { - ArgumentGuard.NotNull(context, nameof(context)); + ArgumentNullException.ThrowIfNull(context); - return context.HttpContext.IsJsonApiRequest(); - } + var reader = context.HttpContext.RequestServices.GetRequiredService(); - /// - public async Task ReadAsync(InputFormatterContext context) - { - ArgumentGuard.NotNull(context, nameof(context)); + object? model = await reader.ReadAsync(context.HttpContext.Request); - var reader = context.HttpContext.RequestServices.GetRequiredService(); - return await reader.ReadAsync(context); - } + return model == null ? await InputFormatterResult.NoValueAsync() : await InputFormatterResult.SuccessAsync(model); } } 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 6d55598a81..2084676c8a 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs @@ -1,322 +1,251 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; using System.Net; -using System.Net.Http; -using System.Text; -using System.Threading.Tasks; +using System.Text.Json; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Diagnostics; +using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Features; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; -using Newtonsoft.Json; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +/// +/// Intercepts HTTP requests to populate injected instance for JSON:API requests. +/// +[PublicAPI] +public sealed partial class JsonApiMiddleware { - /// - /// Intercepts HTTP requests to populate injected instance for JSON:API requests. - /// - [PublicAPI] - public sealed class JsonApiMiddleware + 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) { - private static readonly MediaTypeHeaderValue MediaType = MediaTypeHeaderValue.Parse(HeaderConstants.MediaType); - private static readonly MediaTypeHeaderValue AtomicOperationsMediaType = MediaTypeHeaderValue.Parse(HeaderConstants.AtomicOperationsMediaType); - - private readonly RequestDelegate _next; + 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 JsonApiMiddleware(RequestDelegate next) - { - _next = next; - } + public async Task InvokeAsync(HttpContext httpContext, IJsonApiRequest request) + { + ArgumentNullException.ThrowIfNull(httpContext); + ArgumentNullException.ThrowIfNull(request); - public async Task InvokeAsync(HttpContext httpContext, IControllerResourceMapping controllerResourceMapping, IJsonApiOptions options, - IJsonApiRequest request, IResourceContextProvider resourceContextProvider) + using (CodeTimingSessionManager.Current.Measure("JSON:API middleware")) { - ArgumentGuard.NotNull(httpContext, nameof(httpContext)); - ArgumentGuard.NotNull(controllerResourceMapping, nameof(controllerResourceMapping)); - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider)); - - if (!await ValidateIfMatchHeaderAsync(httpContext, options.SerializerSettings)) - { - return; - } - RouteValueDictionary routeValues = httpContext.GetRouteData().Values; - ResourceContext primaryResourceContext = CreatePrimaryResourceContext(httpContext, controllerResourceMapping, resourceContextProvider); + ResourceType? primaryResourceType = CreatePrimaryResourceType(httpContext, _controllerResourceMapping); - if (primaryResourceContext != null) + bool isResourceRequest = primaryResourceType != null; + bool isOperationsRequest = IsRouteForOperations(routeValues); + + if (isResourceRequest || isOperationsRequest) { - if (!await ValidateContentTypeHeaderAsync(HeaderConstants.MediaType, httpContext, options.SerializerSettings) || - !await ValidateAcceptHeaderAsync(MediaType, httpContext, options.SerializerSettings)) + try { - return; - } + ValidateIfMatchHeader(httpContext.Request); + IReadOnlySet extensions = _contentNegotiator.Negotiate(); - SetupResourceRequest((JsonApiRequest)request, primaryResourceContext, routeValues, options, resourceContextProvider, httpContext.Request); + if (isResourceRequest) + { + SetupResourceRequest((JsonApiRequest)request, primaryResourceType!, routeValues, httpContext.Request, extensions); + } + else + { + SetupOperationsRequest((JsonApiRequest)request, extensions); + } - httpContext.RegisterJsonApiRequest(); - } - else if (IsOperationsRequest(routeValues)) - { - if (!await ValidateContentTypeHeaderAsync(HeaderConstants.AtomicOperationsMediaType, httpContext, options.SerializerSettings) || - !await ValidateAcceptHeaderAsync(AtomicOperationsMediaType, httpContext, options.SerializerSettings)) + httpContext.RegisterJsonApiRequest(); + } + catch (JsonApiException exception) { + await FlushResponseAsync(httpContext.Response, _options.SerializerWriteOptions, exception); return; } - - SetupOperationsRequest((JsonApiRequest)request, options, httpContext.Request); - - httpContext.RegisterJsonApiRequest(); } - // Workaround for bug https://github.com/dotnet/aspnetcore/issues/33394 - httpContext.Features.Set(new FixedQueryFeature(httpContext.Features)); - - await _next(httpContext); - } - - private async Task ValidateIfMatchHeaderAsync(HttpContext httpContext, JsonSerializerSettings serializerSettings) - { - if (httpContext.Request.Headers.ContainsKey(HeaderNames.IfMatch)) + if (_next != null) { - await FlushResponseAsync(httpContext.Response, serializerSettings, new Error(HttpStatusCode.PreconditionFailed) + using (CodeTimingSessionManager.Current.Measure("Subsequent middleware")) { - Title = "Detection of mid-air edit collisions using ETags is not supported." - }); - - return false; + await _next(httpContext); + } } - - return true; } - private static ResourceContext CreatePrimaryResourceContext(HttpContext httpContext, IControllerResourceMapping controllerResourceMapping, - IResourceContextProvider resourceContextProvider) + if (CodeTimingSessionManager.IsEnabled && _logger.IsEnabled(LogLevel.Information)) { - Endpoint endpoint = httpContext.GetEndpoint(); - var controllerActionDescriptor = endpoint?.Metadata.GetMetadata(); - - if (controllerActionDescriptor != null) - { - Type controllerType = controllerActionDescriptor.ControllerTypeInfo; - Type resourceType = controllerResourceMapping.GetResourceTypeForController(controllerType); - - if (resourceType != null) - { - return resourceContextProvider.GetResourceContext(resourceType); - } - } - - return null; + string timingResults = CodeTimingSessionManager.Current.GetResults(); + string requestMethod = httpContext.Request.Method.Replace(Environment.NewLine, ""); + string requestUrl = httpContext.Request.GetEncodedUrl(); + LogMeasurement(requestMethod, requestUrl, Environment.NewLine, timingResults); } + } - private static async Task ValidateContentTypeHeaderAsync(string allowedContentType, HttpContext httpContext, - JsonSerializerSettings serializerSettings) + private void ValidateIfMatchHeader(HttpRequest httpRequest) + { + if (httpRequest.Headers.ContainsKey(HeaderNames.IfMatch)) { - string contentType = httpContext.Request.ContentType; - - if (contentType != null && contentType != allowedContentType) + throw new JsonApiException(new ErrorObject(HttpStatusCode.PreconditionFailed) { - await FlushResponseAsync(httpContext.Response, serializerSettings, new Error(HttpStatusCode.UnsupportedMediaType) + Title = "Detection of mid-air edit collisions using ETags is not supported.", + Source = new ErrorSource { - Title = "The specified Content-Type header value is not supported.", - Detail = $"Please specify '{allowedContentType}' instead of '{contentType}' " + "for the Content-Type header value." - }); - - return false; - } - - return true; + Header = "If-Match" + } + }); } + } - private static async Task ValidateAcceptHeaderAsync(MediaTypeHeaderValue allowedMediaTypeValue, HttpContext httpContext, - JsonSerializerSettings serializerSettings) - { - string[] acceptHeaders = httpContext.Request.Headers.GetCommaSeparatedValues("Accept"); - - if (!acceptHeaders.Any()) - { - return true; - } + private static ResourceType? CreatePrimaryResourceType(HttpContext httpContext, IControllerResourceMapping controllerResourceMapping) + { + Endpoint? endpoint = httpContext.GetEndpoint(); + var controllerActionDescriptor = endpoint?.Metadata.GetMetadata(); - bool seenCompatibleMediaType = false; + return controllerActionDescriptor != null + ? controllerResourceMapping.GetResourceTypeForController(controllerActionDescriptor.ControllerTypeInfo) + : null; + } - foreach (string acceptHeader in acceptHeaders) - { - if (MediaTypeHeaderValue.TryParse(acceptHeader, out MediaTypeHeaderValue headerValue)) - { - headerValue.Quality = null; + private static void SetupResourceRequest(JsonApiRequest request, ResourceType primaryResourceType, RouteValueDictionary routeValues, + HttpRequest httpRequest, IReadOnlySet extensions) + { + AssertNoAtomicOperationsExtension(extensions); - if (headerValue.MediaType == "*/*" || headerValue.MediaType == "application/*") - { - seenCompatibleMediaType = true; - break; - } + request.IsReadOnly = httpRequest.Method == HttpMethod.Get.Method || httpRequest.Method == HttpMethod.Head.Method; + request.PrimaryResourceType = primaryResourceType; + request.PrimaryId = GetPrimaryRequestId(routeValues); - if (allowedMediaTypeValue.Equals(headerValue)) - { - seenCompatibleMediaType = true; - break; - } - } - } + string? relationshipName = GetRelationshipNameForSecondaryRequest(routeValues); - if (!seenCompatibleMediaType) - { - await FlushResponseAsync(httpContext.Response, serializerSettings, new Error(HttpStatusCode.NotAcceptable) - { - Title = "The specified Accept header value does not contain any supported media types.", - Detail = $"Please include '{allowedMediaTypeValue}' in the Accept header values." - }); + if (relationshipName != null) + { + request.Kind = IsRouteForRelationship(routeValues) ? EndpointKind.Relationship : EndpointKind.Secondary; - return false; - } + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true - return true; - } + request.WriteOperation = + httpRequest.Method == HttpMethod.Post.Method ? WriteOperationKind.AddToRelationship : + httpRequest.Method == HttpMethod.Patch.Method ? WriteOperationKind.SetRelationship : + httpRequest.Method == HttpMethod.Delete.Method ? WriteOperationKind.RemoveFromRelationship : null; - private static async Task FlushResponseAsync(HttpResponse httpResponse, JsonSerializerSettings serializerSettings, Error error) - { - httpResponse.ContentType = HeaderConstants.MediaType; - httpResponse.StatusCode = (int)error.StatusCode; + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore - var serializer = JsonSerializer.CreateDefault(serializerSettings); - serializer.ApplyErrorSettings(); + RelationshipAttribute? requestRelationship = primaryResourceType.FindRelationshipByPublicName(relationshipName); - // https://github.com/JamesNK/Newtonsoft.Json/issues/1193 - await using (var stream = new MemoryStream()) + if (requestRelationship != null) { - await using (var streamWriter = new StreamWriter(stream, leaveOpen: true)) - { - using var jsonWriter = new JsonTextWriter(streamWriter); - serializer.Serialize(jsonWriter, new ErrorDocument(error)); - } - - stream.Seek(0, SeekOrigin.Begin); - await stream.CopyToAsync(httpResponse.Body); + request.Relationship = requestRelationship; + request.SecondaryResourceType = requestRelationship.RightType; } - - await httpResponse.Body.FlushAsync(); } - - private static void SetupResourceRequest(JsonApiRequest request, ResourceContext primaryResourceContext, RouteValueDictionary routeValues, - IJsonApiOptions options, IResourceContextProvider resourceContextProvider, HttpRequest httpRequest) + else { - request.IsReadOnly = httpRequest.Method == HttpMethod.Get.Method || httpRequest.Method == HttpMethod.Head.Method; request.Kind = EndpointKind.Primary; - request.PrimaryResource = primaryResourceContext; - request.PrimaryId = GetPrimaryRequestId(routeValues); - request.BasePath = GetBasePath(primaryResourceContext.PublicName, options, httpRequest); - string relationshipName = GetRelationshipNameForSecondaryRequest(routeValues); + // @formatter:wrap_chained_method_calls chop_always + // @formatter:keep_existing_linebreaks true - if (relationshipName != null) - { - request.Kind = IsRouteForRelationship(routeValues) ? EndpointKind.Relationship : EndpointKind.Secondary; - - RelationshipAttribute requestRelationship = - primaryResourceContext.Relationships.SingleOrDefault(relationship => relationship.PublicName == relationshipName); - - if (requestRelationship != null) - { - request.Relationship = requestRelationship; - request.SecondaryResource = resourceContextProvider.GetResourceContext(requestRelationship.RightType); - } - } + request.WriteOperation = + httpRequest.Method == HttpMethod.Post.Method ? WriteOperationKind.CreateResource : + httpRequest.Method == HttpMethod.Patch.Method ? WriteOperationKind.UpdateResource : + httpRequest.Method == HttpMethod.Delete.Method ? WriteOperationKind.DeleteResource : null; - bool isGetAll = request.PrimaryId == null && request.IsReadOnly; - request.IsCollection = isGetAll || request.Relationship is HasManyAttribute; + // @formatter:keep_existing_linebreaks restore + // @formatter:wrap_chained_method_calls restore } - private static string GetPrimaryRequestId(RouteValueDictionary routeValues) - { - return routeValues.TryGetValue("id", out object id) ? (string)id : null; - } + bool isGetAll = request.PrimaryId == null && request.IsReadOnly; + request.IsCollection = isGetAll || request.Relationship is HasManyAttribute; + request.Extensions = extensions; + } - private static string GetBasePath(string resourceName, IJsonApiOptions options, HttpRequest httpRequest) + private static void AssertNoAtomicOperationsExtension(IReadOnlySet extensions) + { + if (extensions.Contains(JsonApiMediaTypeExtension.AtomicOperations) || extensions.Contains(JsonApiMediaTypeExtension.RelaxedAtomicOperations)) { - var builder = new StringBuilder(); - - if (!options.UseRelativeLinks) - { - builder.Append(httpRequest.Scheme); - builder.Append("://"); - builder.Append(httpRequest.Host); - } - - if (httpRequest.PathBase.HasValue) - { - builder.Append(httpRequest.PathBase); - } + throw new InvalidOperationException("Incorrect content negotiation implementation detected: Unexpected atomic:operations extension found."); + } + } - string customRoute = GetCustomRoute(resourceName, options.Namespace, httpRequest.HttpContext); + private static string? GetPrimaryRequestId(RouteValueDictionary routeValues) + { + return routeValues.TryGetValue("id", out object? id) ? (string?)id : null; + } - if (!string.IsNullOrEmpty(customRoute)) - { - builder.Append('/'); - builder.Append(customRoute); - } - else if (!string.IsNullOrEmpty(options.Namespace)) - { - builder.Append('/'); - builder.Append(options.Namespace); - } + private static string? GetRelationshipNameForSecondaryRequest(RouteValueDictionary routeValues) + { + return routeValues.TryGetValue("relationshipName", out object? routeValue) ? (string?)routeValue : null; + } - return builder.ToString(); - } + private static bool IsRouteForRelationship(RouteValueDictionary routeValues) + { + string actionName = (string)routeValues["action"]!; + return actionName.EndsWith("Relationship", StringComparison.Ordinal); + } - private static string GetCustomRoute(string resourceName, string apiNamespace, HttpContext httpContext) - { - if (resourceName != null) - { - Endpoint endpoint = httpContext.GetEndpoint(); - var routeAttribute = endpoint.Metadata.GetMetadata(); + internal static bool IsRouteForOperations(RouteValueDictionary routeValues) + { + string actionName = (string)routeValues["action"]!; + return actionName == "PostOperations"; + } - if (routeAttribute != null) - { - List trimmedComponents = httpContext.Request.Path.Value.Trim('/').Split('/').ToList(); - int resourceNameIndex = trimmedComponents.FindIndex(component => component == resourceName); - string[] newComponents = trimmedComponents.Take(resourceNameIndex).ToArray(); - string customRoute = string.Join('/', newComponents); - return customRoute == apiNamespace ? null : customRoute; - } - } + private static void SetupOperationsRequest(JsonApiRequest request, IReadOnlySet extensions) + { + AssertHasAtomicOperationsExtension(extensions); - return null; - } + request.IsReadOnly = false; + request.Kind = EndpointKind.AtomicOperations; + request.Extensions = extensions; + } - private static string GetRelationshipNameForSecondaryRequest(RouteValueDictionary routeValues) + private static void AssertHasAtomicOperationsExtension(IReadOnlySet extensions) + { + if (!extensions.Contains(JsonApiMediaTypeExtension.AtomicOperations) && !extensions.Contains(JsonApiMediaTypeExtension.RelaxedAtomicOperations)) { - return routeValues.TryGetValue("relationshipName", out object routeValue) ? (string)routeValue : null; + throw new InvalidOperationException("Incorrect content negotiation implementation detected: Missing atomic:operations extension."); } + } - private static bool IsRouteForRelationship(RouteValueDictionary routeValues) - { - string actionName = (string)routeValues["action"]; - return actionName.EndsWith("Relationship", StringComparison.Ordinal); - } + private static async Task FlushResponseAsync(HttpResponse httpResponse, JsonSerializerOptions serializerOptions, JsonApiException exception) + { + httpResponse.ContentType = JsonApiMediaType.Default.ToString(); + httpResponse.StatusCode = (int)ErrorObject.GetResponseStatusCode(exception.Errors); - private static bool IsOperationsRequest(RouteValueDictionary routeValues) + var errorDocument = new Document { - string actionName = (string)routeValues["action"]; - return actionName == "PostOperations"; - } + Errors = exception.Errors.ToList() + }; - private static void SetupOperationsRequest(JsonApiRequest request, IJsonApiOptions options, HttpRequest httpRequest) - { - request.IsReadOnly = false; - request.Kind = EndpointKind.AtomicOperations; - request.BasePath = GetBasePath(null, options, httpRequest); - } + 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 bd66f66067..1f6de39b8b 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs @@ -1,28 +1,26 @@ -using System.Threading.Tasks; -using JsonApiDotNetCore.Serialization; +using JsonApiDotNetCore.Serialization.Response; using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.Extensions.DependencyInjection; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +/// +public sealed class JsonApiOutputFormatter : IJsonApiOutputFormatter { /// - public sealed class JsonApiOutputFormatter : IJsonApiOutputFormatter + public bool CanWriteResult(OutputFormatterCanWriteContext context) { - /// - public bool CanWriteResult(OutputFormatterCanWriteContext context) - { - ArgumentGuard.NotNull(context, nameof(context)); + ArgumentNullException.ThrowIfNull(context); - return context.HttpContext.IsJsonApiRequest(); - } + return context.HttpContext.IsJsonApiRequest(); + } - /// - public async Task WriteAsync(OutputFormatterWriteContext context) - { - ArgumentGuard.NotNull(context, nameof(context)); + /// + public async Task WriteAsync(OutputFormatterWriteContext context) + { + ArgumentNullException.ThrowIfNull(context); - var writer = context.HttpContext.RequestServices.GetRequiredService(); - await writer.WriteAsync(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 b732529e2d..4f382ec329 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs @@ -2,59 +2,58 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +/// +[PublicAPI] +public sealed class JsonApiRequest : IJsonApiRequest { + private static readonly IReadOnlySet EmptyExtensionSet = new HashSet().AsReadOnly(); + + /// + public EndpointKind Kind { get; set; } + + /// + public string? PrimaryId { get; set; } + + /// + public ResourceType? PrimaryResourceType { get; set; } + + /// + public ResourceType? SecondaryResourceType { get; set; } + + /// + public RelationshipAttribute? Relationship { get; set; } + + /// + public bool IsCollection { get; set; } + + /// + public bool IsReadOnly { get; set; } + + /// + public WriteOperationKind? WriteOperation { get; set; } + + /// + public string? TransactionId { get; set; } + + /// + public IReadOnlySet Extensions { get; set; } = EmptyExtensionSet; + /// - [PublicAPI] - public sealed class JsonApiRequest : IJsonApiRequest + public void CopyFrom(IJsonApiRequest other) { - /// - public EndpointKind Kind { get; set; } - - /// - public string BasePath { get; set; } - - /// - public string PrimaryId { get; set; } - - /// - public ResourceContext PrimaryResource { get; set; } - - /// - public ResourceContext SecondaryResource { get; set; } - - /// - public RelationshipAttribute Relationship { get; set; } - - /// - public bool IsCollection { get; set; } - - /// - public bool IsReadOnly { get; set; } - - /// - public OperationKind? OperationKind { get; set; } - - /// - public string TransactionId { get; set; } - - /// - public void CopyFrom(IJsonApiRequest other) - { - ArgumentGuard.NotNull(other, nameof(other)); - - Kind = other.Kind; -#pragma warning disable CS0618 // Type or member is obsolete - BasePath = other.BasePath; -#pragma warning restore CS0618 // Type or member is obsolete - PrimaryId = other.PrimaryId; - PrimaryResource = other.PrimaryResource; - SecondaryResource = other.SecondaryResource; - Relationship = other.Relationship; - IsCollection = other.IsCollection; - IsReadOnly = other.IsReadOnly; - OperationKind = other.OperationKind; - TransactionId = other.TransactionId; - } + ArgumentNullException.ThrowIfNull(other); + + Kind = other.Kind; + PrimaryId = other.PrimaryId; + PrimaryResourceType = other.PrimaryResourceType; + SecondaryResourceType = other.SecondaryResourceType; + Relationship = other.Relationship; + IsCollection = other.IsCollection; + 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 1259aa08e5..fd55d4ec5b 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Reflection; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; @@ -10,186 +7,239 @@ using JsonApiDotNetCore.Resources; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApplicationModels; - -namespace JsonApiDotNetCore.Middleware +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCore.Middleware; + +/// +/// 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). +/// +/// : JsonApiController { } // => /someResources +/// +/// // when using kebab-case naming convention in options: +/// public class RandomNameController : JsonApiController { } // => /some-resources +/// +/// // unable to determine resource type: +/// public class SomeVeryCustomController : CoreJsonApiController { } // => /someVeryCustom +/// ]]> +[PublicAPI] +public sealed partial class JsonApiRoutingConvention : IJsonApiRoutingConvention { - /// - /// 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. - /// - /// { } // => /someResources/relationship/relatedResource - /// - /// public class RandomNameController : JsonApiController { } // => /someResources/relationship/relatedResource - /// - /// // when using kebab-case naming convention: - /// public class SomeResourceController : JsonApiController { } // => /some-resources/relationship/related-resource - /// - /// public class SomeVeryCustomController : CoreJsonApiController { } // => /someVeryCustoms/relationship/relatedResource - /// ]]> - [PublicAPI] - public class JsonApiRoutingConvention : IJsonApiRoutingConvention + private readonly IJsonApiOptions _options; + private readonly 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) { - private readonly IJsonApiOptions _options; - private readonly IResourceContextProvider _resourceContextProvider; - private readonly Dictionary _registeredControllerNameByTemplate = new Dictionary(); - private readonly Dictionary _resourceContextPerControllerTypeMap = new Dictionary(); - private readonly Dictionary _controllerPerResourceContextMap = new Dictionary(); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(resourceGraph); + ArgumentNullException.ThrowIfNull(jsonApiEndpointFilter); + ArgumentNullException.ThrowIfNull(logger); + + _options = options; + _resourceGraph = resourceGraph; + _jsonApiEndpointFilter = jsonApiEndpointFilter; + _logger = logger; + } - public JsonApiRoutingConvention(IJsonApiOptions options, IResourceContextProvider resourceContextProvider) - { - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider)); + /// + public ResourceType? GetResourceTypeForController(Type? controllerType) + { + return controllerType != null && _resourceTypePerControllerTypeMap.TryGetValue(controllerType, out ResourceType? resourceType) ? resourceType : null; + } - _options = options; - _resourceContextProvider = resourceContextProvider; - } + /// + public string? GetControllerNameForResourceType(ResourceType? resourceType) + { + return resourceType != null && _controllerPerResourceTypeMap.TryGetValue(resourceType, out ControllerModel? controllerModel) + ? controllerModel.ControllerName + : null; + } - /// - public Type GetResourceTypeForController(Type controllerType) - { - ArgumentGuard.NotNull(controllerType, nameof(controllerType)); + /// + public void Apply(ApplicationModel application) + { + ArgumentNullException.ThrowIfNull(application); - if (_resourceContextPerControllerTypeMap.TryGetValue(controllerType, out ResourceContext resourceContext)) + foreach (ControllerModel controller in application.Controllers) + { + if (!IsJsonApiController(controller)) { - return resourceContext.ResourceType; + continue; } - return null; - } - - /// - public string GetControllerNameForResourceType(Type resourceType) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - - ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(resourceType); - - if (_controllerPerResourceContextMap.TryGetValue(resourceContext, out ControllerModel controllerModel)) - + if (HasApiControllerAttribute(controller)) { - return controllerModel.ControllerName; - } + // 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. - return null; - } + // 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. - /// - public void Apply(ApplicationModel application) - { - ArgumentGuard.NotNull(application, nameof(application)); + LogApiControllerAttributeFound(controller.ControllerType); + } - foreach (ControllerModel controller in application.Controllers) + if (!IsOperationsController(controller.ControllerType)) { - bool isOperationsController = IsOperationsController(controller.ControllerType); + Type? resourceClrType = ExtractResourceClrTypeFromController(controller.ControllerType); - if (!isOperationsController) + if (resourceClrType != null) { - Type resourceType = ExtractResourceTypeFromController(controller.ControllerType); + ResourceType? resourceType = _resourceGraph.FindResourceType(resourceClrType); - if (resourceType != null) + if (resourceType == null) { - ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(resourceType); - - if (resourceContext != null) - { - _resourceContextPerControllerTypeMap.Add(controller.ControllerType, resourceContext); - _controllerPerResourceContextMap.Add(resourceContext, controller); - } + throw new InvalidConfigurationException( + $"Controller '{controller.ControllerType}' depends on resource type '{resourceClrType}', which does not exist in the resource graph."); } - } - if (!IsRoutingConventionEnabled(controller)) - { - continue; - } + if (_controllerPerResourceTypeMap.TryGetValue(resourceType, out ControllerModel? existingModel)) + { + throw new InvalidConfigurationException( + $"Multiple controllers found for resource type '{resourceType}': '{existingModel.ControllerType}' and '{controller.ControllerType}'."); + } - string template = TemplateFromResource(controller) ?? TemplateFromController(controller); + RemoveDisabledActionMethods(controller, resourceType); - if (_registeredControllerNameByTemplate.ContainsKey(template)) - { - throw new InvalidConfigurationException( - $"Cannot register '{controller.ControllerType.FullName}' for template '{template}' because '{_registeredControllerNameByTemplate[template]}' was already registered for this template."); + _resourceTypePerControllerTypeMap.Add(controller.ControllerType, resourceType); + _controllerPerResourceTypeMap.Add(resourceType, controller); } + } + else + { + var options = (JsonApiOptions)_options; + options.IncludeExtensions(JsonApiMediaTypeExtension.AtomicOperations, JsonApiMediaTypeExtension.RelaxedAtomicOperations); + } - _registeredControllerNameByTemplate.Add(template, controller.ControllerType.FullName); - - controller.Selectors[0].AttributeRouteModel = new AttributeRouteModel - { - Template = template - }; + if (IsRoutingConventionDisabled(controller)) + { + continue; } - } - private bool IsRoutingConventionEnabled(ControllerModel controller) - { - return controller.ControllerType.IsSubclassOf(typeof(CoreJsonApiController)) && - controller.ControllerType.GetCustomAttribute() == null; - } + string template = TemplateFromResource(controller) ?? TemplateFromController(controller); - /// - /// Derives a template from the resource type, and checks if this template was already registered. - /// - private string TemplateFromResource(ControllerModel model) - { - if (_resourceContextPerControllerTypeMap.TryGetValue(model.ControllerType, out ResourceContext resourceContext)) + if (_registeredControllerNameByTemplate.TryGetValue(template, out string? controllerName)) { - return $"{_options.Namespace}/{resourceContext.PublicName}"; + throw new InvalidConfigurationException( + $"Cannot register '{controller.ControllerType.FullName}' for template '{template}' because '{controllerName}' was already registered for this template."); } - return null; - } + _registeredControllerNameByTemplate.Add(template, controller.ControllerType.FullName!); - /// - /// Derives a template from the controller name, and checks if this template was already registered. - /// - private string TemplateFromController(ControllerModel model) - { - string controllerName = _options.SerializerNamingStrategy.GetPropertyName(model.ControllerName, false); - return $"{_options.Namespace}/{controllerName}"; + controller.Selectors[0].AttributeRouteModel = new AttributeRouteModel + { + Template = template + }; } + } - /// - /// Determines the resource associated to a controller by inspecting generic arguments in its inheritance tree. - /// - private Type ExtractResourceTypeFromController(Type type) + private static bool IsJsonApiController(ControllerModel controller) + { + return controller.ControllerType.IsSubclassOf(typeof(CoreJsonApiController)); + } + + private static bool HasApiControllerAttribute(ControllerModel controller) + { + return controller.ControllerType.GetCustomAttribute() != null; + } + + private static bool IsOperationsController(Type type) + { + Type baseControllerType = typeof(BaseJsonApiOperationsController); + return baseControllerType.IsAssignableFrom(type); + } + + /// + /// Determines the resource type associated to a controller by inspecting generic type arguments in its inheritance tree. + /// + private Type? ExtractResourceClrTypeFromController(Type controllerType) + { + Type aspNetControllerType = typeof(ControllerBase); + Type coreControllerType = typeof(CoreJsonApiController); + Type baseControllerUnboundType = typeof(BaseJsonApiController<,>); + Type? currentType = controllerType; + + while (!currentType.IsGenericType || currentType.GetGenericTypeDefinition() != baseControllerUnboundType) { - Type aspNetControllerType = typeof(ControllerBase); - Type coreControllerType = typeof(CoreJsonApiController); - Type baseControllerType = typeof(BaseJsonApiController<,>); - Type currentType = type; + Type? nextBaseType = currentType.BaseType; - while (!currentType.IsGenericType || currentType.GetGenericTypeDefinition() != baseControllerType) + if ((nextBaseType == aspNetControllerType || nextBaseType == coreControllerType) && currentType.IsGenericType) { - Type nextBaseType = currentType.BaseType; + Type? resourceClrType = currentType.GetGenericArguments().FirstOrDefault(typeArgument => typeArgument.IsOrImplementsInterface()); - if ((nextBaseType == aspNetControllerType || nextBaseType == coreControllerType) && currentType.IsGenericType) + if (resourceClrType != null) { - Type resourceType = currentType.GetGenericArguments() - .FirstOrDefault(typeArgument => typeArgument.IsOrImplementsInterface(typeof(IIdentifiable))); - - if (resourceType != null) - { - return resourceType; - } + return resourceClrType; } + } - currentType = nextBaseType; + currentType = nextBaseType; - if (nextBaseType == null) - { - break; - } + if (currentType == null) + { + break; } + } - return currentType?.GetGenericArguments().First(); + return currentType?.GetGenericArguments().First(); + } + + private void RemoveDisabledActionMethods(ControllerModel controller, ResourceType resourceType) + { + 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 IsOperationsController(Type type) + 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)) { - Type baseControllerType = typeof(BaseJsonApiOperationsController); - return baseControllerType.IsAssignableFrom(type); + 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/OperationKind.cs b/src/JsonApiDotNetCore/Middleware/OperationKind.cs deleted file mode 100644 index e3a528f2b0..0000000000 --- a/src/JsonApiDotNetCore/Middleware/OperationKind.cs +++ /dev/null @@ -1,39 +0,0 @@ -namespace JsonApiDotNetCore.Middleware -{ - /// - /// Lists the functional operation kinds of a resource request or an atomic:operations request. - /// - public enum OperationKind - { - /// - /// Create a new resource with attributes, relationships or both. - /// - CreateResource, - - /// - /// 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. - /// - UpdateResource, - - /// - /// Delete an existing resource. - /// - DeleteResource, - - /// - /// Perform a complete replacement of a relationship on an existing resource. - /// - SetRelationship, - - /// - /// Add resources to a to-many relationship. - /// - AddToRelationship, - - /// - /// Remove resources from a to-many relationship. - /// - RemoveFromRelationship - } -} diff --git a/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs b/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs index bd9d4f12ac..23e6733a44 100644 --- a/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs +++ b/src/JsonApiDotNetCore/Middleware/TraceLogWriter.cs @@ -1,146 +1,251 @@ -using System; +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; -using Newtonsoft.Json; -namespace JsonApiDotNetCore.Middleware +namespace JsonApiDotNetCore.Middleware; + +internal abstract class TraceLogWriter { - internal sealed class TraceLogWriter + protected static readonly JsonSerializerOptions SerializerOptions = new() { - private readonly ILogger _logger; + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + ReferenceHandler = ReferenceHandler.IgnoreCycles, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = + { + new JsonStringEnumConverter(), + new ResourceTypeInTraceJsonConverter(), + new ResourceFieldInTraceJsonConverterFactory(), + new AbstractResourceWrapperInTraceJsonConverterFactory(), + new IdentifiableInTraceJsonConverter() + } + }; - private bool IsEnabled => _logger.IsEnabled(LogLevel.Trace); + private sealed class ResourceTypeInTraceJsonConverter : JsonConverter + { + public override ResourceType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotSupportedException(); + } - public TraceLogWriter(ILoggerFactory loggerFactory) + public override void Write(Utf8JsonWriter writer, ResourceType value, JsonSerializerOptions options) { - _logger = loggerFactory.CreateLogger(typeof(T)); + writer.WriteStringValue(value.PublicName); } + } - public void LogMethodStart(object parameters = null, [CallerMemberName] string memberName = "") + private sealed class ResourceFieldInTraceJsonConverterFactory : JsonConverterFactory + { + public override bool CanConvert(Type typeToConvert) { - if (IsEnabled) - { - string message = FormatMessage(memberName, parameters); - WriteMessageToLog(message); - } + return typeToConvert.IsAssignableTo(typeof(ResourceFieldAttribute)); } - public void LogMessage(Func messageFactory) + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) { - if (IsEnabled) - { - string message = messageFactory(); - WriteMessageToLog(message); - } + Type converterType = typeof(ResourceFieldInTraceJsonConverter<>).MakeGenericType(typeToConvert); + return (JsonConverter)Activator.CreateInstance(converterType)!; } - private static string FormatMessage(string memberName, object parameters) + private sealed class ResourceFieldInTraceJsonConverter : JsonConverter + where TField : ResourceFieldAttribute { - var builder = new StringBuilder(); + public override TField Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotSupportedException(); + } - builder.Append("Entering "); - builder.Append(memberName); - builder.Append('('); - WriteProperties(builder, parameters); - builder.Append(')'); + public override void Write(Utf8JsonWriter writer, TField value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.PublicName); + } + } + } - return builder.ToString(); + private sealed class IdentifiableInTraceJsonConverter : JsonConverter + { + public override IIdentifiable Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotSupportedException(); } - private static void WriteProperties(StringBuilder builder, object propertyContainer) + public override void Write(Utf8JsonWriter writer, IIdentifiable value, JsonSerializerOptions options) { - if (propertyContainer != null) - { - bool isFirstMember = true; + // Intentionally *not* calling GetClrType() because we need delegation to the wrapper converter. + Type runtimeType = value.GetType(); - foreach (PropertyInfo property in propertyContainer.GetType().GetProperties()) - { - if (isFirstMember) - { - isFirstMember = false; - } - else - { - builder.Append(", "); - } - - WriteProperty(builder, property, propertyContainer); - } - } + JsonSerializer.Serialize(writer, value, runtimeType, options); } + } - private static void WriteProperty(StringBuilder builder, PropertyInfo property, object instance) + private sealed class AbstractResourceWrapperInTraceJsonConverterFactory : JsonConverterFactory + { + public override bool CanConvert(Type typeToConvert) { - builder.Append(property.Name); - builder.Append(": "); + return typeToConvert.IsAssignableTo(typeof(IAbstractResourceWrapper)); + } - object value = property.GetValue(instance); + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + Type converterType = typeof(AbstractResourceWrapperInTraceJsonConverter<>).MakeGenericType(typeToConvert); + return (JsonConverter)Activator.CreateInstance(converterType)!; + } - if (value == null) - { - builder.Append("null"); - } - else if (value is string stringValue) + private sealed class AbstractResourceWrapperInTraceJsonConverter : JsonConverter + where TWrapper : IAbstractResourceWrapper + { + public override TWrapper Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - builder.Append('"'); - builder.Append(stringValue); - builder.Append('"'); + throw new NotSupportedException(); } - else + + public override void Write(Utf8JsonWriter writer, TWrapper value, JsonSerializerOptions options) { - WriteObject(builder, value); + writer.WriteStartObject(); + writer.WriteString("ClrType", value.AbstractType.FullName); + writer.WriteString("StringId", value.StringId); + writer.WriteEndObject(); } } + } +} - private static void WriteObject(StringBuilder builder, object value) +internal sealed partial class TraceLogWriter(ILoggerFactory loggerFactory) : TraceLogWriter +{ + private readonly ILogger _logger = loggerFactory.CreateLogger(); + + public void LogMethodStart(object? parameters = null, [CallerMemberName] string memberName = "") + { + if (_logger.IsEnabled(LogLevel.Trace)) { - if (HasToStringOverload(value.GetType())) + var builder = new StringBuilder(); + WriteProperties(builder, parameters); + string parameterValues = builder.ToString(); + + if (parameterValues.Length == 0) { - builder.Append(value); + LogEnteringMember(memberName); } else { - string text = SerializeObject(value); - builder.Append(text); + LogEnteringMemberWithParameters(memberName, parameterValues); } } + } - private static bool HasToStringOverload(Type type) + private static void WriteProperties(StringBuilder builder, object? propertyContainer) + { + if (propertyContainer != null) { - if (type != null) - { - MethodInfo toStringMethod = type.GetMethod("ToString", Array.Empty()); + bool isFirstMember = true; - if (toStringMethod != null && toStringMethod.DeclaringType != typeof(object)) + foreach (PropertyInfo property in propertyContainer.GetType().GetProperties()) + { + if (isFirstMember) + { + isFirstMember = false; + } + else { - return true; + builder.Append(", "); } + + WriteProperty(builder, property, propertyContainer); } + } + } + + private static void WriteProperty(StringBuilder builder, PropertyInfo property, object instance) + { + builder.Append(property.Name); + builder.Append(": "); + + object? value = property.GetValue(instance); + WriteObject(builder, value); + } + + private static void WriteObject(StringBuilder builder, object? value) + { + if (value != null && value is not string && HasToStringOverload(value.GetType())) + { + builder.Append(value); + } + else + { + string text = SerializeObject(value); + builder.Append(text); + } + } + + private static bool HasToStringOverload(Type type) + { + MethodInfo? toStringMethod = type.GetMethod("ToString", []); + return toStringMethod != null && toStringMethod.DeclaringType != typeof(object); + } - return false; + private static string SerializeObject(object? value) + { + try + { + return JsonSerializer.Serialize(value, SerializerOptions); } + catch (Exception exception) when (exception is JsonException or NotSupportedException) + { + // Never crash as a result of logging, this is best-effort only. + return "object"; + } + } + + public void LogDebug(QueryLayer queryLayer) + { + ArgumentNullException.ThrowIfNull(queryLayer); + + LogQueryLayer(queryLayer); + } + + public void LogDebug(Expression expression) + { + ArgumentNullException.ThrowIfNull(expression); - private static string SerializeObject(object value) + if (_logger.IsEnabled(LogLevel.Debug)) { - try + string? text = ExpressionTreeFormatter.Instance.GetText(expression); + + if (text != null) { - // It turns out setting ReferenceLoopHandling to something other than Error only takes longer to fail. - // This is because Newtonsoft.Json always tries to serialize the first element in a graph. And with - // EF Core models, that one is often recursive, resulting in either StackOverflowException or OutOfMemoryException. - return JsonConvert.SerializeObject(value, Formatting.Indented); + LogExpression(text); } - catch (JsonSerializationException) + else { - // Never crash as a result of logging, this is best-effort only. - return "object"; + LogReadableExpressionsAssemblyUnavailable(); } } - - private void WriteMessageToLog(string message) - { - _logger.LogTrace(message); - } } + + [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/Middleware/WriteOperationKind.cs b/src/JsonApiDotNetCore/Middleware/WriteOperationKind.cs new file mode 100644 index 0000000000..b5c553d533 --- /dev/null +++ b/src/JsonApiDotNetCore/Middleware/WriteOperationKind.cs @@ -0,0 +1,39 @@ +namespace JsonApiDotNetCore.Middleware; + +/// +/// Lists the functional write operations, originating from a POST/PATCH/DELETE request against a single resource/relationship or a POST request against +/// a list of operations. +/// +public enum WriteOperationKind +{ + /// + /// Create a new resource with attributes, relationships or both. + /// + CreateResource, + + /// + /// 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. + /// + UpdateResource, + + /// + /// Delete an existing resource. + /// + DeleteResource, + + /// + /// Perform a complete replacement of a relationship on an existing resource. + /// + SetRelationship, + + /// + /// Add resources to a to-many relationship. + /// + AddToRelationship, + + /// + /// Remove resources from a to-many relationship. + /// + RemoveFromRelationship +} diff --git a/src/JsonApiDotNetCore/ObjectExtensions.cs b/src/JsonApiDotNetCore/ObjectExtensions.cs deleted file mode 100644 index b1bb1eb4a7..0000000000 --- a/src/JsonApiDotNetCore/ObjectExtensions.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Collections.Generic; - -#pragma warning disable AV1130 // Return type in method signature should be a collection interface instead of a concrete type - -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 - }; - } - } -} diff --git a/src/JsonApiDotNetCore/Properties/AssemblyInfo.cs b/src/JsonApiDotNetCore/Properties/AssemblyInfo.cs index 2c359f920d..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("JsonApiDotNetCoreExampleTests")] +[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/Properties/launchSettings.json b/src/JsonApiDotNetCore/Properties/launchSettings.json deleted file mode 100644 index 233fb4a18e..0000000000 --- a/src/JsonApiDotNetCore/Properties/launchSettings.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:63521/", - "sslPort": 0 - } - }, - "profiles": { - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "JsonApiDotNetCore": { - "commandName": "Project", - "launchBrowser": true, - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - }, - "applicationUrl": "http://localhost:63522/" - } - } -} 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 5475baed85..4cab3e203b 100644 --- a/src/JsonApiDotNetCore/Queries/ExpressionInScope.cs +++ b/src/JsonApiDotNetCore/Queries/ExpressionInScope.cs @@ -2,28 +2,27 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; -namespace JsonApiDotNetCore.Queries +namespace JsonApiDotNetCore.Queries; + +/// +/// Represents an expression coming from query string. The scope determines at which depth in the to apply its expression. +/// +[PublicAPI] +public class ExpressionInScope { - /// - /// Represents an expression coming from query string. The scope determines at which depth in the to apply its expression. - /// - [PublicAPI] - public class ExpressionInScope - { - public ResourceFieldChainExpression Scope { get; } - public QueryExpression Expression { get; } + public ResourceFieldChainExpression? Scope { get; } + public QueryExpression Expression { get; } - public ExpressionInScope(ResourceFieldChainExpression scope, QueryExpression expression) - { - ArgumentGuard.NotNull(expression, nameof(expression)); + public ExpressionInScope(ResourceFieldChainExpression? scope, QueryExpression expression) + { + ArgumentNullException.ThrowIfNull(expression); - Scope = scope; - Expression = expression; - } + Scope = scope; + Expression = expression; + } - public override string ToString() - { - return $"{Scope} => {Expression}"; - } + public override string ToString() + { + return $"{Scope} => {Expression}"; } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs new file mode 100644 index 0000000000..306a98d1aa --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/AnyExpression.cs @@ -0,0 +1,96 @@ +using System.Collections.Immutable; +using System.Text; +using JetBrains.Annotations; +using JsonApiDotNetCore.Queries.Parsing; + +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// 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) + { + ArgumentNullException.ThrowIfNull(targetAttribute); + ArgumentGuard.NotNullNorEmpty(constants); + + TargetAttribute = targetAttribute; + Constants = constants; + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitAny(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(Keywords.Any); + builder.Append('('); + builder.Append(toFullString ? TargetAttribute.ToFullString() : TargetAttribute); + builder.Append(','); + builder.Append(string.Join(",", Constants.Select(constant => toFullString ? constant.ToFullString() : constant.ToString()).OrderBy(value => value))); + 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 = (AnyExpression)obj; + + return TargetAttribute.Equals(other.TargetAttribute) && Constants.SetEquals(other.Constants); + } + + public override int GetHashCode() + { + var hashCode = new HashCode(); + hashCode.Add(TargetAttribute); + + foreach (LiteralConstantExpression constant in Constants) + { + hashCode.Add(constant); + } + + return hashCode.ToHashCode(); + } +} diff --git a/src/JsonApiDotNetCore/Queries/Expressions/CollectionNotEmptyExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/CollectionNotEmptyExpression.cs deleted file mode 100644 index 5aaa958feb..0000000000 --- a/src/JsonApiDotNetCore/Queries/Expressions/CollectionNotEmptyExpression.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System; -using System.Text; -using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Internal.Parsing; - -namespace JsonApiDotNetCore.Queries.Expressions -{ - /// - /// Represents the "has" filter function, resulting from text such as: has(articles) or has(articles,equals(isHidden,'false')) - /// - [PublicAPI] - public class CollectionNotEmptyExpression : FilterExpression - { - public ResourceFieldChainExpression TargetCollection { get; } - public FilterExpression Filter { get; } - - public CollectionNotEmptyExpression(ResourceFieldChainExpression targetCollection, FilterExpression filter) - { - ArgumentGuard.NotNull(targetCollection, nameof(targetCollection)); - - TargetCollection = targetCollection; - Filter = filter; - } - - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitCollectionNotEmpty(this, argument); - } - - public override string ToString() - { - var builder = new StringBuilder(); - builder.Append(Keywords.Has); - builder.Append('('); - builder.Append(TargetCollection); - - if (Filter != null) - { - builder.Append(','); - builder.Append(Filter); - } - - 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 = (CollectionNotEmptyExpression)obj; - - return TargetCollection.Equals(other.TargetCollection) && Equals(Filter, other.Filter); - } - - public override int GetHashCode() - { - return HashCode.Combine(TargetCollection, Filter); - } - } -} diff --git a/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs index ab06961738..b768f556ce 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/ComparisonExpression.cs @@ -1,59 +1,87 @@ -using System; using Humanizer; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// 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 { /// - /// Represents a comparison filter function, resulting from text such as: equals(name,'Joe') + /// The operator used to compare and . /// - [PublicAPI] - public class ComparisonExpression : FilterExpression - { - public ComparisonOperator Operator { get; } - public QueryExpression Left { get; } - public QueryExpression Right { get; } + public ComparisonOperator Operator { get; } - public ComparisonExpression(ComparisonOperator @operator, QueryExpression left, QueryExpression right) - { - ArgumentGuard.NotNull(left, nameof(left)); - ArgumentGuard.NotNull(right, nameof(right)); + /// + /// 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; } - Operator = @operator; - Left = left; - Right = right; - } + /// + /// 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 override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitComparison(this, argument); - } + public ComparisonExpression(ComparisonOperator @operator, QueryExpression left, QueryExpression right) + { + ArgumentNullException.ThrowIfNull(left); + ArgumentNullException.ThrowIfNull(right); - public override string ToString() - { - return $"{Operator.ToString().Camelize()}({Left},{Right})"; - } + Operator = @operator; + Left = left; + Right = right; + } - public override bool Equals(object obj) - { - if (ReferenceEquals(this, obj)) - { - return true; - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitComparison(this, argument); + } - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + public override string ToString() + { + return $"{Operator.ToString().Camelize()}({Left},{Right})"; + } - var other = (ComparisonExpression)obj; + public override string ToFullString() + { + return $"{Operator.ToString().Camelize()}({Left.ToFullString()},{Right.ToFullString()})"; + } - return Operator == other.Operator && Left.Equals(other.Left) && Right.Equals(other.Right); + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; } - public override int GetHashCode() + if (obj is null || GetType() != obj.GetType()) { - return HashCode.Combine(Operator, Left, Right); + return false; } + + var other = (ComparisonExpression)obj; + + return Operator == other.Operator && Left.Equals(other.Left) && Right.Equals(other.Right); + } + + public override int GetHashCode() + { + return HashCode.Combine(Operator, Left, Right); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/ComparisonOperator.cs b/src/JsonApiDotNetCore/Queries/Expressions/ComparisonOperator.cs index fbe12f1b0c..ca06d02254 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/ComparisonOperator.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/ComparisonOperator.cs @@ -1,11 +1,10 @@ -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +public enum ComparisonOperator { - public enum ComparisonOperator - { - Equals, - GreaterThan, - GreaterOrEqual, - LessThan, - LessOrEqual - } + Equals, + GreaterThan, + GreaterOrEqual, + LessThan, + LessOrEqual } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs index 57163936f7..f79c49af1b 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/CountExpression.cs @@ -1,53 +1,70 @@ using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// 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 { /// - /// Represents the "count" function, resulting from text such as: count(articles) + /// 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 . /// - [PublicAPI] - public class CountExpression : FunctionExpression + public override Type ReturnType { get; } = typeof(int); + + public CountExpression(ResourceFieldChainExpression targetCollection) { - public ResourceFieldChainExpression TargetCollection { get; } + ArgumentNullException.ThrowIfNull(targetCollection); - public CountExpression(ResourceFieldChainExpression targetCollection) - { - ArgumentGuard.NotNull(targetCollection, nameof(targetCollection)); + TargetCollection = targetCollection; + } - TargetCollection = targetCollection; - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitCount(this, argument); + } - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitCount(this, argument); - } + public override string ToString() + { + return $"{Keywords.Count}({TargetCollection})"; + } + + public override string ToFullString() + { + return $"{Keywords.Count}({TargetCollection.ToFullString()})"; + } - public override string ToString() + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) { - return $"{Keywords.Count}({TargetCollection})"; + return true; } - public override bool Equals(object obj) + if (obj is null || GetType() != obj.GetType()) { - if (ReferenceEquals(this, obj)) - { - return true; - } - - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + return false; + } - var other = (CountExpression)obj; + var other = (CountExpression)obj; - return TargetCollection.Equals(other.TargetCollection); - } + return TargetCollection.Equals(other.TargetCollection); + } - public override int GetHashCode() - { - return TargetCollection.GetHashCode(); - } + public override int GetHashCode() + { + return TargetCollection.GetHashCode(); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/EqualsAnyOfExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/EqualsAnyOfExpression.cs deleted file mode 100644 index 5e723bb5bf..0000000000 --- a/src/JsonApiDotNetCore/Queries/Expressions/EqualsAnyOfExpression.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Internal.Parsing; - -namespace JsonApiDotNetCore.Queries.Expressions -{ - /// - /// Represents the "any" filter function, resulting from text such as: any(name,'Jack','Joe') - /// - [PublicAPI] - public class EqualsAnyOfExpression : FilterExpression - { - public ResourceFieldChainExpression TargetAttribute { get; } - public IReadOnlyCollection Constants { get; } - - public EqualsAnyOfExpression(ResourceFieldChainExpression targetAttribute, IReadOnlyCollection 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)); - } - - TargetAttribute = targetAttribute; - Constants = constants; - } - - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitEqualsAnyOf(this, argument); - } - - public override string ToString() - { - var builder = new StringBuilder(); - - builder.Append(Keywords.Any); - builder.Append('('); - builder.Append(TargetAttribute); - builder.Append(','); - builder.Append(string.Join(",", Constants.Select(constant => constant.ToString()))); - 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 = (EqualsAnyOfExpression)obj; - - return TargetAttribute.Equals(other.TargetAttribute) && Constants.SequenceEqual(other.Constants); - } - - public override int GetHashCode() - { - var hashCode = new HashCode(); - hashCode.Add(TargetAttribute); - - foreach (LiteralConstantExpression constant in Constants) - { - hashCode.Add(constant); - } - - return hashCode.ToHashCode(); - } - } -} diff --git a/src/JsonApiDotNetCore/Queries/Expressions/FilterExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/FilterExpression.cs index 1ed0ed1de0..447c8b6138 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/FilterExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/FilterExpression.cs @@ -1,9 +1,12 @@ -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents the base type for filter functions that return a boolean value. +/// +public abstract class FilterExpression : FunctionExpression { /// - /// Represents the base type for filter functions that return a boolean value. + /// The CLR type this function returns, which is always . /// - public abstract class FilterExpression : FunctionExpression - { - } + public override Type ReturnType { get; } = typeof(bool); } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/FunctionExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/FunctionExpression.cs index 6d7990a16a..886a3906c8 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/FunctionExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/FunctionExpression.cs @@ -1,9 +1,12 @@ -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents the base type for functions that return a value. +/// +public abstract class FunctionExpression : QueryExpression { /// - /// Represents the base type for functions that return a value. + /// The CLR type this function returns. /// - public abstract class FunctionExpression : QueryExpression - { - } + public abstract Type ReturnType { get; } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs new file mode 100644 index 0000000000..5c40039d4e --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/HasExpression.cs @@ -0,0 +1,95 @@ +using System.Text; +using JetBrains.Annotations; +using JsonApiDotNetCore.Queries.Parsing; + +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// 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) + { + ArgumentNullException.ThrowIfNull(targetCollection); + + TargetCollection = targetCollection; + Filter = filter; + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitHas(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(Keywords.Has); + builder.Append('('); + builder.Append(toFullString ? TargetCollection.ToFullString() : TargetCollection); + + if (Filter != null) + { + builder.Append(','); + builder.Append(toFullString ? Filter.ToFullString() : Filter); + } + + 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 = (HasExpression)obj; + + return TargetCollection.Equals(other.TargetCollection) && Equals(Filter, other.Filter); + } + + public override int GetHashCode() + { + return HashCode.Combine(TargetCollection, Filter); + } +} diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IdentifierExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IdentifierExpression.cs index 974c4892de..5a978b54f6 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IdentifierExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IdentifierExpression.cs @@ -1,9 +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. - /// - public abstract class IdentifierExpression : QueryExpression - { - } -} +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// 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; diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs b/src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs index d3fde6d0cf..c729c692b9 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeChainConverter.cs @@ -1,165 +1,93 @@ -using System; -using System.Collections.Generic; -using System.Linq; +using System.Collections.Immutable; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Converts includes between tree and chain formats. Exists for backwards compatibility, subject to be removed in the future. +/// +internal sealed class IncludeChainConverter { /// - /// Converts includes between tree and chain formats. Exists for backwards compatibility, subject to be removed in the future. + /// Converts a tree of inclusions into a set of relationship chains. /// - internal sealed class IncludeChainConverter + /// + /// Input tree: Output chains: + /// Blog, + /// Article -> Revisions -> Author + /// ]]> + /// + public IReadOnlyCollection GetRelationshipChains(IncludeExpression include) { - /// - /// Converts a tree of inclusions into a set of relationship chains. - /// - /// - /// Input tree: Output chains: - /// Blog, - /// Article -> Revisions -> Author - /// ]]> - /// - public IReadOnlyCollection GetRelationshipChains(IncludeExpression include) + ArgumentNullException.ThrowIfNull(include); + + if (include.Elements.Count == 0) { - ArgumentGuard.NotNull(include, nameof(include)); + return Array.Empty(); + } - if (!include.Elements.Any()) - { - return Array.Empty(); - } + var converter = new IncludeToChainsConverter(); + converter.Visit(include, null); - var converter = new IncludeToChainsConverter(); - converter.Visit(include, null); + return converter.Chains.AsReadOnly(); + } - return converter.Chains; - } + private sealed class IncludeToChainsConverter : QueryExpressionVisitor + { + private readonly Stack _parentRelationshipStack = new(); - /// - /// Converts a set of relationship chains into a tree of inclusions. - /// - /// - /// Input chains: Blog, - /// Article -> Revisions -> Author - /// ]]> Output tree: - /// - /// - public IncludeExpression FromRelationshipChains(IReadOnlyCollection chains) - { - ArgumentGuard.NotNull(chains, nameof(chains)); + public List Chains { get; } = []; - IReadOnlyCollection elements = ConvertChainsToElements(chains); - return elements.Any() ? new IncludeExpression(elements) : IncludeExpression.Empty; - } - - private static IReadOnlyCollection ConvertChainsToElements(IReadOnlyCollection chains) + public override object? VisitInclude(IncludeExpression expression, object? argument) { - var rootNode = new MutableIncludeNode(null); - - foreach (ResourceFieldChainExpression chain in chains) + foreach (IncludeElementExpression element in expression.Elements) { - ConvertChainToElement(chain, rootNode); + Visit(element, null); } - return rootNode.Children.Values.Select(child => child.ToExpression()).ToArray(); + return null; } - private static void ConvertChainToElement(ResourceFieldChainExpression chain, MutableIncludeNode rootNode) + public override object? VisitIncludeElement(IncludeElementExpression expression, object? argument) { - MutableIncludeNode currentNode = rootNode; - - foreach (RelationshipAttribute relationship in chain.Fields.OfType()) + if (expression.Children.Count == 0) { - if (!currentNode.Children.ContainsKey(relationship)) - { - currentNode.Children[relationship] = new MutableIncludeNode(relationship); - } - - currentNode = currentNode.Children[relationship]; + FlushChain(expression); } - } - - private sealed class IncludeToChainsConverter : QueryExpressionVisitor - { - private readonly Stack _parentRelationshipStack = new Stack(); - - public List Chains { get; } = new List(); - - public override object VisitInclude(IncludeExpression expression, object argument) + else { - foreach (IncludeElementExpression element in expression.Elements) - { - Visit(element, null); - } - - return null; - } + _parentRelationshipStack.Push(expression.Relationship); - public override object VisitIncludeElement(IncludeElementExpression expression, object argument) - { - if (!expression.Children.Any()) + foreach (IncludeElementExpression child in expression.Children) { - FlushChain(expression); + Visit(child, null); } - else - { - _parentRelationshipStack.Push(expression.Relationship); - foreach (IncludeElementExpression child in expression.Children) - { - Visit(child, null); - } - - _parentRelationshipStack.Pop(); - } - - return null; + _parentRelationshipStack.Pop(); } - private void FlushChain(IncludeElementExpression expression) - { - List fieldsInChain = _parentRelationshipStack.Reverse().ToList(); - fieldsInChain.Add(expression.Relationship); - - Chains.Add(new ResourceFieldChainExpression(fieldsInChain)); - } + return null; } - private sealed class MutableIncludeNode + private void FlushChain(IncludeElementExpression expression) { - private readonly RelationshipAttribute _relationship; + ImmutableArray.Builder chainBuilder = + ImmutableArray.CreateBuilder(_parentRelationshipStack.Count + 1); - public IDictionary Children { get; } = new Dictionary(); + chainBuilder.AddRange(_parentRelationshipStack.Reverse()); + chainBuilder.Add(expression.Relationship); - public MutableIncludeNode(RelationshipAttribute relationship) - { - _relationship = relationship; - } - - public IncludeElementExpression ToExpression() - { - IncludeElementExpression[] elementChildren = Children.Values.Select(child => child.ToExpression()).ToArray(); - return new IncludeElementExpression(_relationship, elementChildren); - } + Chains.Add(new ResourceFieldChainExpression(chainBuilder.ToImmutable())); } } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs index f279f4ede4..9e98deefe3 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeElementExpression.cs @@ -1,83 +1,101 @@ -using System; -using System.Collections.Generic; -using System.Linq; +using System.Collections.Immutable; using System.Text; using JetBrains.Annotations; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents an element in an tree, resulting from text such as: +/// +/// articles.revisions +/// +/// . +/// +[PublicAPI] +public class IncludeElementExpression : QueryExpression { /// - /// Represents an element in . + /// The JSON:API relationship to include. /// - [PublicAPI] - public class IncludeElementExpression : QueryExpression + public RelationshipAttribute Relationship { get; } + + /// + /// The direct children of this subtree. Can be empty. + /// + public IImmutableSet Children { get; } + + public IncludeElementExpression(RelationshipAttribute relationship) + : this(relationship, ImmutableHashSet.Empty) { - public RelationshipAttribute Relationship { get; } - public IReadOnlyCollection Children { get; } + } - public IncludeElementExpression(RelationshipAttribute relationship) - : this(relationship, Array.Empty()) - { - } + public IncludeElementExpression(RelationshipAttribute relationship, IImmutableSet children) + { + ArgumentNullException.ThrowIfNull(relationship); + ArgumentNullException.ThrowIfNull(children); - public IncludeElementExpression(RelationshipAttribute relationship, IReadOnlyCollection children) - { - ArgumentGuard.NotNull(relationship, nameof(relationship)); - ArgumentGuard.NotNull(children, nameof(children)); + Relationship = relationship; + Children = children; + } - Relationship = relationship; - Children = children; - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitIncludeElement(this, argument); + } - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitIncludeElement(this, argument); - } + public override string ToString() + { + return InnerToString(false); + } - public override string ToString() + public override string ToFullString() + { + return InnerToString(true); + } + + private string InnerToString(bool toFullString) + { + var builder = new StringBuilder(); + builder.Append(toFullString ? $"{Relationship.LeftType.PublicName}:{Relationship.PublicName}" : Relationship.PublicName); + + if (Children.Count > 0) { - var builder = new StringBuilder(); - builder.Append(Relationship); + builder.Append('{'); + builder.Append(string.Join(",", Children.Select(child => toFullString ? child.ToFullString() : child.ToString()).OrderBy(name => name))); + builder.Append('}'); + } - if (Children.Any()) - { - builder.Append('{'); - builder.Append(string.Join(",", Children.Select(child => child.ToString()))); - builder.Append('}'); - } + return builder.ToString(); + } - return builder.ToString(); + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; } - public override bool Equals(object obj) + if (obj is null || GetType() != obj.GetType()) { - if (ReferenceEquals(this, obj)) - { - return true; - } + return false; + } - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + var other = (IncludeElementExpression)obj; - var other = (IncludeElementExpression)obj; + return Relationship.Equals(other.Relationship) && Children.SetEquals(other.Children); + } - return Relationship.Equals(other.Relationship) == Children.SequenceEqual(other.Children); - } + public override int GetHashCode() + { + var hashCode = new HashCode(); + hashCode.Add(Relationship); - public override int GetHashCode() + foreach (IncludeElementExpression child in Children) { - var hashCode = new HashCode(); - hashCode.Add(Relationship); - - foreach (IncludeElementExpression child in Children) - { - hashCode.Add(child); - } - - return hashCode.ToHashCode(); + hashCode.Add(child); } + + return hashCode.ToHashCode(); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs index 9b72d050e7..235e811fff 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/IncludeExpression.cs @@ -1,72 +1,86 @@ -using System; -using System.Collections.Generic; -using System.Linq; +using System.Collections.Immutable; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents an inclusion tree, resulting from text such as: +/// +/// owner,articles.revisions +/// +/// . +/// +[PublicAPI] +public class IncludeExpression : QueryExpression { + private static readonly IncludeChainConverter IncludeChainConverter = new(); + + public static readonly IncludeExpression Empty = new(); + /// - /// Represents an inclusion tree, resulting from text such as: owner,articles.revisions + /// The direct children of this tree. Use if there are no children. /// - [PublicAPI] - public class IncludeExpression : QueryExpression + public IImmutableSet Elements { get; } + + public IncludeExpression(IImmutableSet elements) { - private static readonly IncludeChainConverter IncludeChainConverter = new IncludeChainConverter(); + ArgumentGuard.NotNullNorEmpty(elements); - public static readonly IncludeExpression Empty = new IncludeExpression(); + Elements = elements; + } - public IReadOnlyCollection Elements { get; } + private IncludeExpression() + { + Elements = ImmutableHashSet.Empty; + } - public IncludeExpression(IReadOnlyCollection elements) - { - ArgumentGuard.NotNullNorEmpty(elements, nameof(elements)); + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitInclude(this, argument); + } - Elements = elements; - } + public override string ToString() + { + return InnerToString(false); + } - private IncludeExpression() - { - Elements = Array.Empty(); - } + public override string ToFullString() + { + return InnerToString(true); + } - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitInclude(this, argument); - } + private string InnerToString(bool toFullString) + { + IReadOnlyCollection chains = IncludeChainConverter.GetRelationshipChains(this); + return string.Join(",", chains.Select(field => toFullString ? field.ToFullString() : field.ToString()).OrderBy(name => name)); + } - public override string ToString() + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) { - IReadOnlyCollection chains = IncludeChainConverter.GetRelationshipChains(this); - return string.Join(",", chains.Select(child => child.ToString())); + return true; } - public override bool Equals(object obj) + if (obj is null || GetType() != obj.GetType()) { - if (ReferenceEquals(this, obj)) - { - return true; - } + return false; + } - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + var other = (IncludeExpression)obj; - var other = (IncludeExpression)obj; + return Elements.SetEquals(other.Elements); + } - return Elements.SequenceEqual(other.Elements); - } + public override int GetHashCode() + { + var hashCode = new HashCode(); - public override int GetHashCode() + foreach (IncludeElementExpression element in Elements) { - var hashCode = new HashCode(); - - foreach (IncludeElementExpression element in Elements) - { - hashCode.Add(element); - } - - return hashCode.ToHashCode(); + hashCode.Add(element); } + + return hashCode.ToHashCode(); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/IsTypeExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/IsTypeExpression.cs new file mode 100644 index 0000000000..e2f68ee8ec --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Expressions/IsTypeExpression.cs @@ -0,0 +1,111 @@ +using System.Text; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Parsing; + +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// 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) + { + ArgumentNullException.ThrowIfNull(derivedType); + + TargetToOneRelationship = targetToOneRelationship; + DerivedType = derivedType; + Child = child; + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitIsType(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(Keywords.IsType); + builder.Append('('); + + if (TargetToOneRelationship != null) + { + builder.Append(toFullString ? TargetToOneRelationship.ToFullString() : TargetToOneRelationship); + } + + builder.Append(','); + builder.Append(DerivedType); + + if (Child != null) + { + builder.Append(','); + builder.Append(toFullString ? Child.ToFullString() : Child); + } + + 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 = (IsTypeExpression)obj; + + return Equals(TargetToOneRelationship, other.TargetToOneRelationship) && DerivedType.Equals(other.DerivedType) && Equals(Child, other.Child); + } + + public override int GetHashCode() + { + return HashCode.Combine(TargetToOneRelationship, DerivedType, Child); + } +} diff --git a/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs index 019301e40c..50c3b2cd54 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/LiteralConstantExpression.cs @@ -1,53 +1,78 @@ +using System.Globalization; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents a non-null constant value, resulting from text such as: 'Jack', '123', or: 'true'. +/// +[PublicAPI] +public class LiteralConstantExpression : IdentifierExpression { + // Only used to show the original input in errors and diagnostics. Not part of the semantic expression value. + private readonly string _stringValue; + /// - /// Represents a non-null constant value, resulting from text such as: equals(firstName,'Jack') + /// The constant value. Call to determine the .NET runtime type. /// - [PublicAPI] - public class LiteralConstantExpression : IdentifierExpression + public object TypedValue { get; } + + public LiteralConstantExpression(object typedValue) + : this(typedValue, GetStringValue(typedValue)!) { - public string Value { get; } + } - public LiteralConstantExpression(string text) - { - ArgumentGuard.NotNull(text, nameof(text)); + public LiteralConstantExpression(object typedValue, string stringValue) + { + ArgumentNullException.ThrowIfNull(typedValue); + ArgumentNullException.ThrowIfNull(stringValue); - Value = text; - } + TypedValue = typedValue; + _stringValue = stringValue; + } - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitLiteralConstant(this, argument); - } + private static string? GetStringValue(object typedValue) + { + ArgumentNullException.ThrowIfNull(typedValue); - public override string ToString() - { - string value = Value.Replace("\'", "\'\'"); - return $"'{value}'"; - } + return typedValue is IFormattable cultureAwareValue ? cultureAwareValue.ToString(null, CultureInfo.InvariantCulture) : typedValue.ToString(); + } - public override bool Equals(object obj) - { - if (ReferenceEquals(this, obj)) - { - return true; - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitLiteralConstant(this, argument); + } - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + public override string ToString() + { + string escapedValue = _stringValue.Replace("\'", "\'\'"); + return $"'{escapedValue}'"; + } - var other = (LiteralConstantExpression)obj; + public override string ToFullString() + { + return ToString(); + } - return Value == other.Value; + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; } - public override int GetHashCode() + if (obj is null || GetType() != obj.GetType()) { - return Value.GetHashCode(); + return false; } + + var other = (LiteralConstantExpression)obj; + + return TypedValue.Equals(other.TypedValue); + } + + public override int GetHashCode() + { + return TypedValue.GetHashCode(); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs index ab991aad9a..416303b06b 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/LogicalExpression.cs @@ -1,79 +1,118 @@ -using System; -using System.Collections.Generic; -using System.Linq; +using System.Collections.Immutable; using System.Text; using Humanizer; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// 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 { /// - /// Represents a logical filter function, resulting from text such as: and(equals(title,'Work'),has(articles)) + /// The operator used to compare . /// - [PublicAPI] - public class LogicalExpression : FilterExpression - { - public LogicalOperator Operator { get; } - public IReadOnlyCollection Terms { get; } + public LogicalOperator Operator { get; } - public LogicalExpression(LogicalOperator @operator, IReadOnlyCollection terms) - { - ArgumentGuard.NotNull(terms, nameof(terms)); + /// + /// The list of one or more boolean operands. + /// + public IImmutableList Terms { get; } - if (terms.Count < 2) - { - throw new ArgumentException("At least two terms are required.", nameof(terms)); - } + public LogicalExpression(LogicalOperator @operator, params FilterExpression[] terms) + : this(@operator, terms.ToImmutableArray()) + { + } - Operator = @operator; - Terms = terms; - } + public LogicalExpression(LogicalOperator @operator, IImmutableList terms) + { + ArgumentNullException.ThrowIfNull(terms); - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + if (terms.Count < 2) { - return visitor.VisitLogical(this, argument); + throw new ArgumentException("At least two terms are required.", nameof(terms)); } - public override string ToString() - { - var builder = new StringBuilder(); + Operator = @operator; + Terms = terms; + } - builder.Append(Operator.ToString().Camelize()); - builder.Append('('); - builder.Append(string.Join(",", Terms.Select(term => term.ToString()))); - builder.Append(')'); + public static FilterExpression? Compose(LogicalOperator @operator, params FilterExpression?[] filters) + { + ArgumentNullException.ThrowIfNull(filters); - return builder.ToString(); - } + // Workaround for https://youtrack.jetbrains.com/issue/RSRP-496512/Invalid-Use-collection-expression-suggestion. + // ReSharper disable once UseCollectionExpression + ImmutableArray terms = filters.WhereNotNull().ToImmutableArray(); - public override bool Equals(object obj) - { - if (ReferenceEquals(this, obj)) - { - return true; - } + return terms.Length > 1 ? new LogicalExpression(@operator, terms) : terms.FirstOrDefault(); + } + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitLogical(this, argument); + } - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + public override string ToString() + { + return InnerToString(false); + } + + public override string ToFullString() + { + return InnerToString(true); + } - var other = (LogicalExpression)obj; + private string InnerToString(bool toFullString) + { + var builder = new StringBuilder(); + + builder.Append(Operator.ToString().Camelize()); + builder.Append('('); + builder.Append(string.Join(",", Terms.Select(term => toFullString ? term.ToFullString() : term.ToString()))); + builder.Append(')'); + + return builder.ToString(); + } - return Operator == other.Operator && Terms.SequenceEqual(other.Terms); + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; } - public override int GetHashCode() + if (obj is null || GetType() != obj.GetType()) { - var hashCode = new HashCode(); - hashCode.Add(Operator); + return false; + } - foreach (QueryExpression term in Terms) - { - hashCode.Add(term); - } + var other = (LogicalExpression)obj; + + return Operator == other.Operator && Terms.SequenceEqual(other.Terms); + } - return hashCode.ToHashCode(); + public override int GetHashCode() + { + var hashCode = new HashCode(); + hashCode.Add(Operator); + + foreach (QueryExpression term in Terms) + { + hashCode.Add(term); } + + return hashCode.ToHashCode(); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/LogicalOperator.cs b/src/JsonApiDotNetCore/Queries/Expressions/LogicalOperator.cs index 3514820f86..c6c838c47e 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/LogicalOperator.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/LogicalOperator.cs @@ -1,8 +1,7 @@ -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +public enum LogicalOperator { - public enum LogicalOperator - { - And, - Or - } + And, + Or } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs index 0df64dbb44..390940e9d1 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/MatchTextExpression.cs @@ -1,67 +1,103 @@ -using System; using System.Text; using Humanizer; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// 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 { /// - /// Represents a text-matching filter function, resulting from text such as: startsWith(name,'A') + /// 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. /// - [PublicAPI] - public class MatchTextExpression : FilterExpression + public LiteralConstantExpression TextValue { get; } + + /// + /// The kind of matching to perform. + /// + public TextMatchKind MatchKind { get; } + + public MatchTextExpression(ResourceFieldChainExpression targetAttribute, LiteralConstantExpression textValue, TextMatchKind matchKind) { - public ResourceFieldChainExpression TargetAttribute { get; } - public LiteralConstantExpression TextValue { get; } - public TextMatchKind MatchKind { get; } + ArgumentNullException.ThrowIfNull(targetAttribute); + ArgumentNullException.ThrowIfNull(textValue); - public MatchTextExpression(ResourceFieldChainExpression targetAttribute, LiteralConstantExpression textValue, TextMatchKind matchKind) - { - ArgumentGuard.NotNull(targetAttribute, nameof(targetAttribute)); - ArgumentGuard.NotNull(textValue, nameof(textValue)); + TargetAttribute = targetAttribute; + TextValue = textValue; + MatchKind = matchKind; + } - TargetAttribute = targetAttribute; - TextValue = textValue; - MatchKind = matchKind; - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitMatchText(this, argument); + } - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitMatchText(this, argument); - } + public override string ToString() + { + return InnerToString(false); + } - public override string ToString() - { - var builder = new StringBuilder(); + public override string ToFullString() + { + return InnerToString(true); + } - builder.Append(MatchKind.ToString().Camelize()); - builder.Append('('); - builder.Append(string.Join(",", TargetAttribute, TextValue)); - builder.Append(')'); + private string InnerToString(bool toFullString) + { + var builder = new StringBuilder(); - return builder.ToString(); - } + builder.Append(MatchKind.ToString().Camelize()); + builder.Append('('); - public override bool Equals(object obj) - { - if (ReferenceEquals(this, obj)) - { - return true; - } + builder.Append(toFullString + ? string.Join(",", TargetAttribute.ToFullString(), TextValue.ToFullString()) + : string.Join(",", TargetAttribute, TextValue)); - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + builder.Append(')'); - var other = (MatchTextExpression)obj; + return builder.ToString(); + } - return TargetAttribute.Equals(other.TargetAttribute) && TextValue.Equals(other.TextValue) && MatchKind == other.MatchKind; + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; } - public override int GetHashCode() + if (obj is null || GetType() != obj.GetType()) { - return HashCode.Combine(TargetAttribute, TextValue, MatchKind); + return false; } + + var other = (MatchTextExpression)obj; + + return TargetAttribute.Equals(other.TargetAttribute) && TextValue.Equals(other.TextValue) && MatchKind == other.MatchKind; + } + + public override int GetHashCode() + { + return HashCode.Combine(TargetAttribute, TextValue, MatchKind); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs index 3ac97b46bc..e9567000d7 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/NotExpression.cs @@ -1,53 +1,64 @@ using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// 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 { /// - /// Represents the "not" filter function, resulting from text such as: not(equals(title,'Work')) + /// The filter whose value to negate. /// - [PublicAPI] - public class NotExpression : FilterExpression + public FilterExpression Child { get; } + + public NotExpression(FilterExpression child) { - public FilterExpression Child { get; } + ArgumentNullException.ThrowIfNull(child); - public NotExpression(FilterExpression child) - { - ArgumentGuard.NotNull(child, nameof(child)); + Child = child; + } - Child = child; - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitNot(this, argument); + } - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitNot(this, argument); - } + public override string ToString() + { + return $"{Keywords.Not}({Child})"; + } - public override string ToString() + public override string ToFullString() + { + return $"{Keywords.Not}({Child.ToFullString()})"; + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) { - return $"{Keywords.Not}({Child})"; + return true; } - public override bool Equals(object obj) + if (obj is null || GetType() != obj.GetType()) { - if (ReferenceEquals(this, obj)) - { - return true; - } - - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + return false; + } - var other = (NotExpression)obj; + var other = (NotExpression)obj; - return Child.Equals(other.Child); - } + return Child.Equals(other.Child); + } - public override int GetHashCode() - { - return Child.GetHashCode(); - } + public override int GetHashCode() + { + return Child.GetHashCode(); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs index 1041be47dd..9685b6625c 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/NullConstantExpression.cs @@ -1,43 +1,55 @@ -using System; using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Internal.Parsing; +using JsonApiDotNetCore.Queries.Parsing; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents the constant null, resulting from the text: null. +/// +[PublicAPI] +public class NullConstantExpression : IdentifierExpression { /// - /// Represents the constant null, resulting from text such as: equals(lastName,null) + /// Provides access to the singleton instance. /// - [PublicAPI] - public class NullConstantExpression : IdentifierExpression + public static readonly NullConstantExpression Instance = new(); + + private NullConstantExpression() { - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitNullConstant(this, argument); - } + } - public override string ToString() - { - return Keywords.Null; - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitNullConstant(this, argument); + } - public override bool Equals(object obj) - { - if (ReferenceEquals(this, obj)) - { - return true; - } + public override string ToString() + { + return Keywords.Null; + } - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + public override string ToFullString() + { + return ToString(); + } + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { return true; } - public override int GetHashCode() + if (obj is null || GetType() != obj.GetType()) { - return new HashCode().ToHashCode(); + return false; } + + return true; + } + + public override int GetHashCode() + { + return new HashCode().ToHashCode(); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs index d62ca621e0..b6e182a714 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/PaginationElementQueryStringValueExpression.cs @@ -1,53 +1,66 @@ -using System; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents an element in , resulting from text such as: 1, or: +/// +/// articles:2 +/// +/// . +/// +[PublicAPI] +public class PaginationElementQueryStringValueExpression(ResourceFieldChainExpression? scope, int value, int position) : QueryExpression { /// - /// Represents an element in . + /// The relationship this pagination applies to. Chain format: zero or more relationships, followed by a to-many relationship. + /// + public ResourceFieldChainExpression? Scope { get; } = scope; + + /// + /// The numeric pagination value. + /// + public int Value { get; } = value; + + /// + /// The zero-based position in the text of the query string parameter value. /// - [PublicAPI] - public class PaginationElementQueryStringValueExpression : QueryExpression + public int Position { get; } = position; + + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) { - public ResourceFieldChainExpression Scope { get; } - public int Value { get; } + return visitor.VisitPaginationElementQueryStringValue(this, argument); + } - public PaginationElementQueryStringValueExpression(ResourceFieldChainExpression scope, int value) - { - Scope = scope; - Value = value; - } + public override string ToString() + { + return Scope == null ? $"{Value} at {Position}" : $"{Scope}: {Value} at {Position}"; + } - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.PaginationElementQueryStringValue(this, argument); - } + public override string ToFullString() + { + return Scope == null ? $"{Value} at {Position}" : $"{Scope.ToFullString()}: {Value} at {Position}"; + } - public override string ToString() + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) { - return Scope == null ? Value.ToString() : $"{Scope}: {Value}"; + return true; } - public override bool Equals(object obj) + if (obj is null || GetType() != obj.GetType()) { - if (ReferenceEquals(this, obj)) - { - return true; - } - - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + return false; + } - var other = (PaginationElementQueryStringValueExpression)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); - } + public override int GetHashCode() + { + return HashCode.Combine(Scope, Value, Position); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/PaginationExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/PaginationExpression.cs index 3d8f2c5870..d9e91ef363 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/PaginationExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/PaginationExpression.cs @@ -1,56 +1,66 @@ -using System; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents a pagination, produced from . +/// +[PublicAPI] +public class PaginationExpression : QueryExpression { /// - /// Represents a pagination, produced from . + /// The one-based page number. + /// + public PageNumber PageNumber { get; } + + /// + /// The optional page size. /// - [PublicAPI] - public class PaginationExpression : QueryExpression + public PageSize? PageSize { get; } + + public PaginationExpression(PageNumber pageNumber, PageSize? pageSize) { - public PageNumber PageNumber { get; } - public PageSize PageSize { get; } + ArgumentNullException.ThrowIfNull(pageNumber); - public PaginationExpression(PageNumber pageNumber, PageSize pageSize) - { - ArgumentGuard.NotNull(pageNumber, nameof(pageNumber)); + PageNumber = pageNumber; + PageSize = pageSize; + } - PageNumber = pageNumber; - PageSize = pageSize; - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitPagination(this, argument); + } - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitPagination(this, argument); - } + public override string ToString() + { + return PageSize != null ? $"Page number: {PageNumber}, size: {PageSize}" : "(none)"; + } + + public override string ToFullString() + { + return ToString(); + } - public override string ToString() + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) { - return PageSize != null ? $"Page number: {PageNumber}, size: {PageSize}" : "(none)"; + return true; } - public override bool Equals(object obj) + if (obj is null || GetType() != obj.GetType()) { - if (ReferenceEquals(this, obj)) - { - return true; - } - - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + return false; + } - var other = (PaginationExpression)obj; + var other = (PaginationExpression)obj; - return PageNumber.Equals(other.PageNumber) && Equals(PageSize, other.PageSize); - } + return PageNumber.Equals(other.PageNumber) && Equals(PageSize, other.PageSize); + } - public override int GetHashCode() - { - return HashCode.Combine(PageNumber, PageSize); - } + public override int GetHashCode() + { + return HashCode.Combine(PageNumber, PageSize); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs index 1850ab0b02..2a70ea7d8c 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/PaginationQueryStringValueExpression.cs @@ -1,62 +1,71 @@ -using System; -using System.Collections.Generic; -using System.Linq; +using System.Collections.Immutable; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents pagination in a query string, resulting from text such as: +/// +/// 1,articles:2 +/// +/// . +/// +[PublicAPI] +public class PaginationQueryStringValueExpression : QueryExpression { /// - /// Represents pagination in a query string, resulting from text such as: 1,articles:2 + /// The list of one or more pagination elements. /// - [PublicAPI] - public class PaginationQueryStringValueExpression : QueryExpression + public IImmutableList Elements { get; } + + public PaginationQueryStringValueExpression(IImmutableList elements) { - public IReadOnlyCollection Elements { get; } + ArgumentGuard.NotNullNorEmpty(elements); - public PaginationQueryStringValueExpression(IReadOnlyCollection elements) - { - ArgumentGuard.NotNullNorEmpty(elements, nameof(elements)); + Elements = elements; + } - Elements = elements; - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitPaginationQueryStringValue(this, argument); + } - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.PaginationQueryStringValue(this, argument); - } + public override string ToString() + { + return string.Join(",", Elements.Select(element => element.ToString())); + } - public override string ToString() + public override string ToFullString() + { + return string.Join(",", Elements.Select(element => element.ToFullString())); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) { - return string.Join(",", Elements.Select(constant => constant.ToString())); + return true; } - public override bool Equals(object obj) + if (obj is null || GetType() != obj.GetType()) { - if (ReferenceEquals(this, obj)) - { - return true; - } + return false; + } - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + var other = (PaginationQueryStringValueExpression)obj; - var other = (PaginationQueryStringValueExpression)obj; + return Elements.SequenceEqual(other.Elements); + } - return Elements.SequenceEqual(other.Elements); - } + public override int GetHashCode() + { + var hashCode = new HashCode(); - public override int GetHashCode() + foreach (PaginationElementQueryStringValueExpression element in Elements) { - var hashCode = new HashCode(); - - foreach (PaginationElementQueryStringValueExpression element in Elements) - { - hashCode.Add(element); - } - - return hashCode.ToHashCode(); + hashCode.Add(element); } + + return hashCode.ToHashCode(); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs index 44d5311b19..dac7493109 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpression.cs @@ -1,13 +1,14 @@ using System.Linq.Expressions; -namespace JsonApiDotNetCore.Queries.Expressions +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.Linq trees that are handled by Entity Framework Core. +/// +public abstract class QueryExpression { - /// - /// 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. - /// - public abstract class QueryExpression - { - public abstract TResult Accept(QueryExpressionVisitor visitor, TArgument argument); - } + public abstract TResult Accept(QueryExpressionVisitor visitor, TArgument argument); + + public abstract string ToFullString(); } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs index 5def9a208a..173c77503c 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionRewriter.cs @@ -1,319 +1,292 @@ -using System.Collections.Generic; -using System.Linq; +using System.Collections.Immutable; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -namespace JsonApiDotNetCore.Queries.Expressions +#pragma warning disable IDE0019 // Use pattern matching + +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Building block for rewriting trees. It walks through nested expressions and updates the parent on changes. +/// +[PublicAPI] +public class QueryExpressionRewriter : QueryExpressionVisitor { - /// - /// Building block for rewriting trees. It walks through nested expressions and updates parent on changes. - /// - [PublicAPI] - public class QueryExpressionRewriter : QueryExpressionVisitor + public override QueryExpression? Visit(QueryExpression expression, TArgument argument) { - public override QueryExpression Visit(QueryExpression expression, TArgument argument) - { - return expression.Accept(this, argument); - } - - public override QueryExpression DefaultVisit(QueryExpression expression, TArgument argument) - { - return expression; - } + return expression.Accept(this, argument); + } - public override QueryExpression VisitComparison(ComparisonExpression expression, TArgument argument) - { - if (expression == null) - { - return null; - } + public override QueryExpression DefaultVisit(QueryExpression expression, TArgument argument) + { + return expression; + } - QueryExpression newLeft = Visit(expression.Left, argument); - QueryExpression newRight = Visit(expression.Right, argument); + public override QueryExpression? VisitComparison(ComparisonExpression expression, TArgument argument) + { + QueryExpression? newLeft = Visit(expression.Left, argument); + QueryExpression? newRight = Visit(expression.Right, argument); + if (newLeft != null && newRight != null) + { var newExpression = new ComparisonExpression(expression.Operator, newLeft, newRight); return newExpression.Equals(expression) ? expression : newExpression; } - public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument) + return null; + } + + public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument) + { + return expression; + } + + public override QueryExpression VisitLiteralConstant(LiteralConstantExpression expression, TArgument argument) + { + return expression; + } + + public override QueryExpression VisitNullConstant(NullConstantExpression expression, TArgument argument) + { + return expression; + } + + public override QueryExpression? VisitLogical(LogicalExpression expression, TArgument argument) + { + IImmutableList newTerms = VisitList(expression.Terms, argument); + + if (newTerms.Count == 1) { - return expression; + return newTerms[0]; } - public override QueryExpression VisitLiteralConstant(LiteralConstantExpression expression, TArgument argument) + if (newTerms.Count != 0) { - return expression; + var newExpression = new LogicalExpression(expression.Operator, newTerms); + return newExpression.Equals(expression) ? expression : newExpression; } - public override QueryExpression VisitNullConstant(NullConstantExpression expression, TArgument argument) + return null; + } + + public override QueryExpression? VisitNot(NotExpression expression, TArgument argument) + { + if (Visit(expression.Child, argument) is FilterExpression newChild) { - return expression; + var newExpression = new NotExpression(newChild); + return newExpression.Equals(expression) ? expression : newExpression; } - public override QueryExpression VisitLogical(LogicalExpression expression, TArgument argument) + return null; + } + + public override QueryExpression? VisitHas(HasExpression expression, TArgument argument) + { + if (Visit(expression.TargetCollection, argument) is ResourceFieldChainExpression newTargetCollection) { - if (expression != null) - { - IReadOnlyCollection newTerms = VisitSequence(expression.Terms, argument); - - if (newTerms.Count == 1) - { - return newTerms.First(); - } - - if (newTerms.Count != 0) - { - var newExpression = new LogicalExpression(expression.Operator, newTerms); - return newExpression.Equals(expression) ? expression : newExpression; - } - } + FilterExpression? newFilter = expression.Filter != null ? Visit(expression.Filter, argument) as FilterExpression : null; - return null; + var newExpression = new HasExpression(newTargetCollection, newFilter); + return newExpression.Equals(expression) ? expression : newExpression; } - public override QueryExpression VisitNot(NotExpression expression, TArgument argument) - { - if (expression != null) - { - if (Visit(expression.Child, argument) is FilterExpression newChild) - { - var newExpression = new NotExpression(newChild); - return newExpression.Equals(expression) ? expression : newExpression; - } - } + return null; + } - return null; - } + public override QueryExpression VisitIsType(IsTypeExpression expression, TArgument argument) + { + ResourceFieldChainExpression? newTargetToOneRelationship = expression.TargetToOneRelationship != null + ? Visit(expression.TargetToOneRelationship, argument) as ResourceFieldChainExpression + : null; - public override QueryExpression VisitCollectionNotEmpty(CollectionNotEmptyExpression expression, TArgument argument) - { - if (expression != null) - { - if (Visit(expression.TargetCollection, argument) is ResourceFieldChainExpression newTargetCollection) - { - FilterExpression newFilter = expression.Filter != null ? Visit(expression.Filter, argument) as FilterExpression : null; + FilterExpression? newChild = expression.Child != null ? Visit(expression.Child, argument) as FilterExpression : null; - var newExpression = new CollectionNotEmptyExpression(newTargetCollection, newFilter); - return newExpression.Equals(expression) ? expression : newExpression; - } - } + var newExpression = new IsTypeExpression(newTargetToOneRelationship, expression.DerivedType, newChild); + return newExpression.Equals(expression) ? expression : newExpression; + } - return null; - } + public override QueryExpression? VisitSortElement(SortElementExpression expression, TArgument argument) + { + QueryExpression? newTarget = Visit(expression.Target, argument); - public override QueryExpression VisitSortElement(SortElementExpression expression, TArgument argument) + if (newTarget != null) { - if (expression != null) - { - 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); - } - } - - if (newExpression != null) - { - return newExpression.Equals(expression) ? expression : newExpression; - } - } - - return null; + var newExpression = new SortElementExpression(newTarget, expression.IsAscending); + return newExpression.Equals(expression) ? expression : newExpression; } - public override QueryExpression VisitSort(SortExpression expression, TArgument argument) - { - if (expression != null) - { - IReadOnlyCollection newElements = VisitSequence(expression.Elements, argument); + return null; + } - if (newElements.Count != 0) - { - var newExpression = new SortExpression(newElements); - return newExpression.Equals(expression) ? expression : newExpression; - } - } + public override QueryExpression? VisitSort(SortExpression expression, TArgument argument) + { + IImmutableList newElements = VisitList(expression.Elements, argument); - return null; + if (newElements.Count != 0) + { + var newExpression = new SortExpression(newElements); + return newExpression.Equals(expression) ? expression : newExpression; } - public override QueryExpression VisitPagination(PaginationExpression expression, TArgument argument) + return null; + } + + public override QueryExpression VisitPagination(PaginationExpression expression, TArgument argument) + { + return expression; + } + + public override QueryExpression? VisitCount(CountExpression expression, TArgument argument) + { + if (Visit(expression.TargetCollection, argument) is ResourceFieldChainExpression newTargetCollection) { - return expression; + var newExpression = new CountExpression(newTargetCollection); + return newExpression.Equals(expression) ? expression : newExpression; } - public override QueryExpression VisitCount(CountExpression expression, TArgument argument) - { - if (expression != null) - { - if (Visit(expression.TargetCollection, argument) is ResourceFieldChainExpression newTargetCollection) - { - var newExpression = new CountExpression(newTargetCollection); - return newExpression.Equals(expression) ? expression : newExpression; - } - } + return null; + } - return null; - } + public override QueryExpression? VisitMatchText(MatchTextExpression expression, TArgument argument) + { + var newTargetAttribute = Visit(expression.TargetAttribute, argument) as ResourceFieldChainExpression; + var newTextValue = Visit(expression.TextValue, argument) as LiteralConstantExpression; - public override QueryExpression VisitMatchText(MatchTextExpression expression, TArgument argument) + if (newTargetAttribute != null && newTextValue != null) { - if (expression != null) - { - var newTargetAttribute = Visit(expression.TargetAttribute, argument) as ResourceFieldChainExpression; - var newTextValue = Visit(expression.TextValue, argument) as LiteralConstantExpression; + var newExpression = new MatchTextExpression(newTargetAttribute, newTextValue, expression.MatchKind); + return newExpression.Equals(expression) ? expression : newExpression; + } - var newExpression = new MatchTextExpression(newTargetAttribute, newTextValue, expression.MatchKind); - return newExpression.Equals(expression) ? expression : newExpression; - } + return null; + } - return null; - } + public override QueryExpression? VisitAny(AnyExpression expression, TArgument argument) + { + var newTargetAttribute = Visit(expression.TargetAttribute, argument) as ResourceFieldChainExpression; + IImmutableSet newConstants = VisitSet(expression.Constants, argument); - public override QueryExpression VisitEqualsAnyOf(EqualsAnyOfExpression expression, TArgument argument) + if (newTargetAttribute != null) { - if (expression != null) - { - var newTargetAttribute = Visit(expression.TargetAttribute, argument) as ResourceFieldChainExpression; - IReadOnlyCollection newConstants = VisitSequence(expression.Constants, argument); + var newExpression = new AnyExpression(newTargetAttribute, newConstants); + return newExpression.Equals(expression) ? expression : newExpression; + } - var newExpression = new EqualsAnyOfExpression(newTargetAttribute, newConstants); - return newExpression.Equals(expression) ? expression : newExpression; - } + return null; + } - return null; - } + public override QueryExpression? VisitSparseFieldTable(SparseFieldTableExpression expression, TArgument argument) + { + ImmutableDictionary.Builder newTable = + ImmutableDictionary.CreateBuilder(); - public override QueryExpression VisitSparseFieldTable(SparseFieldTableExpression expression, TArgument argument) + foreach ((ResourceType resourceType, SparseFieldSetExpression sparseFieldSet) in expression.Table) { - if (expression != null) + if (Visit(sparseFieldSet, argument) is SparseFieldSetExpression newSparseFieldSet) { - var newTable = new Dictionary(); - - foreach ((ResourceContext resourceContext, SparseFieldSetExpression sparseFieldSet) in expression.Table) - { - if (Visit(sparseFieldSet, argument) is SparseFieldSetExpression newSparseFieldSet) - { - newTable[resourceContext] = newSparseFieldSet; - } - } - - if (newTable.Count > 0) - { - var newExpression = new SparseFieldTableExpression(newTable); - return newExpression.Equals(expression) ? expression : newExpression; - } + newTable[resourceType] = newSparseFieldSet; } - - return null; } - public override QueryExpression VisitSparseFieldSet(SparseFieldSetExpression expression, TArgument argument) + if (newTable.Count > 0) { - return expression; + var newExpression = new SparseFieldTableExpression(newTable.ToImmutable()); + return newExpression.Equals(expression) ? expression : newExpression; } - public override QueryExpression VisitQueryStringParameterScope(QueryStringParameterScopeExpression expression, TArgument argument) - { - if (expression != null) - { - var newParameterName = Visit(expression.ParameterName, argument) as LiteralConstantExpression; + return null; + } - ResourceFieldChainExpression newScope = expression.Scope != null ? Visit(expression.Scope, argument) as ResourceFieldChainExpression : null; + public override QueryExpression VisitSparseFieldSet(SparseFieldSetExpression expression, TArgument argument) + { + return expression; + } - var newExpression = new QueryStringParameterScopeExpression(newParameterName, newScope); - return newExpression.Equals(expression) ? expression : newExpression; - } + public override QueryExpression? VisitQueryStringParameterScope(QueryStringParameterScopeExpression expression, TArgument argument) + { + var newParameterName = Visit(expression.ParameterName, argument) as LiteralConstantExpression; + ResourceFieldChainExpression? newScope = expression.Scope != null ? Visit(expression.Scope, argument) as ResourceFieldChainExpression : null; - return null; + if (newParameterName != null) + { + var newExpression = new QueryStringParameterScopeExpression(newParameterName, newScope); + return newExpression.Equals(expression) ? expression : newExpression; } - public override QueryExpression PaginationQueryStringValue(PaginationQueryStringValueExpression expression, TArgument argument) - { - if (expression != null) - { - IReadOnlyCollection newElements = VisitSequence(expression.Elements, argument); + return null; + } - var newExpression = new PaginationQueryStringValueExpression(newElements); - return newExpression.Equals(expression) ? expression : newExpression; - } + public override QueryExpression VisitPaginationQueryStringValue(PaginationQueryStringValueExpression expression, TArgument argument) + { + IImmutableList newElements = VisitList(expression.Elements, argument); - return null; - } + var newExpression = new PaginationQueryStringValueExpression(newElements); + return newExpression.Equals(expression) ? expression : newExpression; + } - public override QueryExpression PaginationElementQueryStringValue(PaginationElementQueryStringValueExpression expression, TArgument argument) - { - if (expression != null) - { - ResourceFieldChainExpression newScope = expression.Scope != null ? Visit(expression.Scope, argument) as ResourceFieldChainExpression : null; + 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); - return newExpression.Equals(expression) ? expression : newExpression; - } + var newExpression = new PaginationElementQueryStringValueExpression(newScope, expression.Value, expression.Position); + return newExpression.Equals(expression) ? expression : newExpression; + } - return null; - } + public override QueryExpression VisitInclude(IncludeExpression expression, TArgument argument) + { + IImmutableSet newElements = VisitSet(expression.Elements, argument); - public override QueryExpression VisitInclude(IncludeExpression expression, TArgument argument) + if (newElements.Count == 0) { - if (expression != null) - { - IReadOnlyCollection newElements = VisitSequence(expression.Elements, argument); + return IncludeExpression.Empty; + } - if (newElements.Count == 0) - { - return IncludeExpression.Empty; - } + var newExpression = new IncludeExpression(newElements); + return newExpression.Equals(expression) ? expression : newExpression; + } - var newExpression = new IncludeExpression(newElements); - return newExpression.Equals(expression) ? expression : newExpression; - } + public override QueryExpression VisitIncludeElement(IncludeElementExpression expression, TArgument argument) + { + IImmutableSet newElements = VisitSet(expression.Children, argument); - return null; - } + var newExpression = new IncludeElementExpression(expression.Relationship, newElements); + return newExpression.Equals(expression) ? expression : newExpression; + } + + public override QueryExpression VisitQueryableHandler(QueryableHandlerExpression expression, TArgument argument) + { + return expression; + } - public override QueryExpression VisitIncludeElement(IncludeElementExpression expression, TArgument argument) + protected virtual IImmutableList VisitList(IImmutableList elements, TArgument argument) + where TExpression : QueryExpression + { + ImmutableArray.Builder arrayBuilder = ImmutableArray.CreateBuilder(elements.Count); + + foreach (TExpression element in elements) { - if (expression != null) + if (Visit(element, argument) is TExpression newElement) { - IReadOnlyCollection newElements = VisitSequence(expression.Children, argument); - - var newExpression = new IncludeElementExpression(expression.Relationship, newElements); - return newExpression.Equals(expression) ? expression : newExpression; + arrayBuilder.Add(newElement); } - - return null; } - public override QueryExpression VisitQueryableHandler(QueryableHandlerExpression expression, TArgument argument) - { - return expression; - } + return arrayBuilder.ToImmutable(); + } - protected virtual IReadOnlyCollection VisitSequence(IEnumerable elements, TArgument argument) - where TExpression : QueryExpression - { - var newElements = new List(); + protected virtual IImmutableSet VisitSet(IImmutableSet elements, TArgument argument) + where TExpression : QueryExpression + { + ImmutableHashSet.Builder setBuilder = ImmutableHashSet.CreateBuilder(); - foreach (TExpression element in elements) + foreach (TExpression element in elements) + { + if (Visit(element, argument) is TExpression newElement) { - if (Visit(element, argument) is TExpression newElement) - { - newElements.Add(newElement); - } + setBuilder.Add(newElement); } - - return newElements; } + + return setBuilder.ToImmutable(); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs index f16a7b0424..a0472306f7 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs @@ -1,126 +1,136 @@ using JetBrains.Annotations; -namespace JsonApiDotNetCore.Queries.Expressions +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 { - /// - /// Implements the visitor design pattern that enables traversing a tree. - /// - [PublicAPI] - public abstract class QueryExpressionVisitor - { - public virtual TResult Visit(QueryExpression expression, TArgument argument) - { - return expression.Accept(this, argument); - } - - public virtual TResult DefaultVisit(QueryExpression expression, TArgument argument) - { - return default; - } - - public virtual TResult VisitComparison(ComparisonExpression expression, TArgument argument) - { - return DefaultVisit(expression, argument); - } - - public virtual TResult VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument) - { - return DefaultVisit(expression, argument); - } - - public virtual TResult VisitLiteralConstant(LiteralConstantExpression expression, TArgument argument) - { - return DefaultVisit(expression, argument); - } - - public virtual TResult VisitNullConstant(NullConstantExpression expression, TArgument argument) - { - return DefaultVisit(expression, argument); - } - - public virtual TResult VisitLogical(LogicalExpression expression, TArgument argument) - { - return DefaultVisit(expression, argument); - } - - public virtual TResult VisitNot(NotExpression expression, TArgument argument) - { - return DefaultVisit(expression, argument); - } - - public virtual TResult VisitCollectionNotEmpty(CollectionNotEmptyExpression expression, TArgument argument) - { - return DefaultVisit(expression, argument); - } - - public virtual TResult VisitSortElement(SortElementExpression expression, TArgument argument) - { - return DefaultVisit(expression, argument); - } - - public virtual TResult VisitSort(SortExpression expression, TArgument argument) - { - return DefaultVisit(expression, argument); - } - - public virtual TResult VisitPagination(PaginationExpression expression, TArgument argument) - { - return DefaultVisit(expression, argument); - } - - public virtual TResult VisitCount(CountExpression expression, TArgument argument) - { - return DefaultVisit(expression, argument); - } - - public virtual TResult VisitMatchText(MatchTextExpression expression, TArgument argument) - { - return DefaultVisit(expression, argument); - } - - public virtual TResult VisitEqualsAnyOf(EqualsAnyOfExpression expression, TArgument argument) - { - return DefaultVisit(expression, argument); - } - - public virtual TResult VisitSparseFieldTable(SparseFieldTableExpression expression, TArgument argument) - { - return DefaultVisit(expression, argument); - } - - public virtual TResult VisitSparseFieldSet(SparseFieldSetExpression expression, TArgument argument) - { - return DefaultVisit(expression, argument); - } - - public virtual TResult VisitQueryStringParameterScope(QueryStringParameterScopeExpression expression, TArgument argument) - { - return DefaultVisit(expression, argument); - } - - public virtual TResult PaginationQueryStringValue(PaginationQueryStringValueExpression expression, TArgument argument) - { - return DefaultVisit(expression, argument); - } - - public virtual TResult PaginationElementQueryStringValue(PaginationElementQueryStringValueExpression expression, TArgument argument) - { - return DefaultVisit(expression, argument); - } - - public virtual TResult VisitInclude(IncludeExpression expression, TArgument argument) - { - return DefaultVisit(expression, argument); - } - - public virtual TResult VisitIncludeElement(IncludeElementExpression expression, TArgument argument) - { - return DefaultVisit(expression, argument); - } - - public virtual TResult VisitQueryableHandler(QueryableHandlerExpression expression, TArgument argument) - { - return DefaultVisit(expression, argument); - } + public virtual TResult Visit(QueryExpression expression, TArgument argument) + { + return expression.Accept(this, argument); + } + + public virtual TResult DefaultVisit(QueryExpression expression, TArgument argument) + { + return default!; + } + + public virtual TResult VisitComparison(ComparisonExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitLiteralConstant(LiteralConstantExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitNullConstant(NullConstantExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitLogical(LogicalExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitNot(NotExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitHas(HasExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitIsType(IsTypeExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitSortElement(SortElementExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitSort(SortExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitPagination(PaginationExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitCount(CountExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitMatchText(MatchTextExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitAny(AnyExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitSparseFieldTable(SparseFieldTableExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitSparseFieldSet(SparseFieldSetExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitQueryStringParameterScope(QueryStringParameterScopeExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitPaginationQueryStringValue(PaginationQueryStringValueExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitPaginationElementQueryStringValue(PaginationElementQueryStringValueExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitInclude(IncludeExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitIncludeElement(IncludeElementExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); + } + + public virtual TResult VisitQueryableHandler(QueryableHandlerExpression expression, TArgument argument) + { + return DefaultVisit(expression, argument); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs index 7a6071e450..4063199dd2 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryStringParameterScopeExpression.cs @@ -1,55 +1,74 @@ -using System; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// 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 { /// - /// Represents the scope of a query string parameter, resulting from text such as: ?filter[articles]=... + /// 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. /// - [PublicAPI] - public class QueryStringParameterScopeExpression : QueryExpression + public ResourceFieldChainExpression? Scope { get; } + + public QueryStringParameterScopeExpression(LiteralConstantExpression parameterName, ResourceFieldChainExpression? scope) { - public LiteralConstantExpression ParameterName { get; } - public ResourceFieldChainExpression Scope { get; } + ArgumentNullException.ThrowIfNull(parameterName); - public QueryStringParameterScopeExpression(LiteralConstantExpression parameterName, ResourceFieldChainExpression scope) - { - ArgumentGuard.NotNull(parameterName, nameof(parameterName)); + ParameterName = parameterName; + Scope = scope; + } - ParameterName = parameterName; - Scope = scope; - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitQueryStringParameterScope(this, argument); + } - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitQueryStringParameterScope(this, argument); - } + public override string ToString() + { + return Scope == null ? ParameterName.ToString() : $"{ParameterName}: {Scope}"; + } + + public override string ToFullString() + { + return Scope == null ? ParameterName.ToFullString() : $"{ParameterName.ToFullString()}: {Scope.ToFullString()}"; + } - public override string ToString() + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) { - return Scope == null ? ParameterName.ToString() : $"{ParameterName}: {Scope}"; + return true; } - public override bool Equals(object obj) + if (obj is null || GetType() != obj.GetType()) { - if (ReferenceEquals(this, obj)) - { - return true; - } - - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + return false; + } - var other = (QueryStringParameterScopeExpression)obj; + var other = (QueryStringParameterScopeExpression)obj; - return ParameterName.Equals(other.ParameterName) && Equals(Scope, other.Scope); - } + return ParameterName.Equals(other.ParameterName) && Equals(Scope, other.Scope); + } - public override int GetHashCode() - { - return HashCode.Combine(ParameterName, Scope); - } + public override int GetHashCode() + { + return HashCode.Combine(ParameterName, Scope); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs index 9ebf5f0c2b..e3894e7e1f 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryableHandlerExpression.cs @@ -1,65 +1,67 @@ -using System; -using System.Linq; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Holds an expression, used for custom query string handlers from s. +/// +[PublicAPI] +public class QueryableHandlerExpression : QueryExpression { - /// - /// Holds an expression, used for custom query string handlers from s. - /// - [PublicAPI] - public class QueryableHandlerExpression : QueryExpression + private readonly object _queryableHandler; + private readonly StringValues _parameterValue; + + public QueryableHandlerExpression(object queryableHandler, StringValues parameterValue) { - private readonly object _queryableHandler; - private readonly StringValues _parameterValue; + ArgumentNullException.ThrowIfNull(queryableHandler); - public QueryableHandlerExpression(object queryableHandler, StringValues parameterValue) - { - ArgumentGuard.NotNull(queryableHandler, nameof(queryableHandler)); + _queryableHandler = queryableHandler; + _parameterValue = parameterValue; + } - _queryableHandler = queryableHandler; - _parameterValue = parameterValue; - } + public IQueryable Apply(IQueryable query) + where TResource : class, IIdentifiable + { + var handler = (Func, StringValues, IQueryable>)_queryableHandler; + return handler(query, _parameterValue); + } - public IQueryable Apply(IQueryable query) - where TResource : class, IIdentifiable - { - var handler = (Func, StringValues, IQueryable>)_queryableHandler; - return handler(query, _parameterValue); - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitQueryableHandler(this, argument); + } - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitQueryableHandler(this, argument); - } + public override string ToString() + { + return $"handler('{_parameterValue}')"; + } - public override string ToString() + public override string ToFullString() + { + return ToString(); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) { - return $"handler('{_parameterValue}')"; + return true; } - public override bool Equals(object obj) + if (obj is null || GetType() != obj.GetType()) { - if (ReferenceEquals(this, obj)) - { - return true; - } - - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + return false; + } - var other = (QueryableHandlerExpression)obj; + var other = (QueryableHandlerExpression)obj; - return _queryableHandler == other._queryableHandler && _parameterValue.Equals(other._parameterValue); - } + return _queryableHandler == other._queryableHandler && _parameterValue.Equals(other._parameterValue); + } - public override int GetHashCode() - { - return HashCode.Combine(_queryableHandler, _parameterValue); - } + public override int GetHashCode() + { + return HashCode.Combine(_queryableHandler, _parameterValue); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs index 2ebec58d17..35d5ecc4a1 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/ResourceFieldChainExpression.cs @@ -1,70 +1,84 @@ -using System; -using System.Collections.Generic; -using System.Linq; +using System.Collections.Immutable; using JetBrains.Annotations; +using JsonApiDotNetCore.QueryStrings.FieldChains; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// 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 { /// - /// Represents a chain of fields (relationships and attributes), resulting from text such as: articles.revisions.author + /// A list of one or more JSON:API fields. Use to convert from text. /// - [PublicAPI] - public class ResourceFieldChainExpression : IdentifierExpression + public IImmutableList Fields { get; } + + public ResourceFieldChainExpression(ResourceFieldAttribute field) { - public IReadOnlyCollection Fields { get; } + ArgumentNullException.ThrowIfNull(field); - public ResourceFieldChainExpression(ResourceFieldAttribute field) - { - ArgumentGuard.NotNull(field, nameof(field)); + Fields = ImmutableArray.Create(field); + } - Fields = field.AsArray(); - } + public ResourceFieldChainExpression(IImmutableList fields) + { + ArgumentGuard.NotNullNorEmpty(fields); - public ResourceFieldChainExpression(IReadOnlyCollection fields) - { - ArgumentGuard.NotNullNorEmpty(fields, nameof(fields)); + Fields = fields; + } - Fields = fields; - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitResourceFieldChain(this, argument); + } - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitResourceFieldChain(this, argument); - } + public override string ToString() + { + return string.Join(".", Fields.Select(field => field.PublicName)); + } - public override string ToString() + public override string ToFullString() + { + return string.Join(".", Fields.Select(field => $"{field.Type.PublicName}:{field.PublicName}")); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) { - return string.Join(".", Fields.Select(field => field.PublicName)); + return true; } - public override bool Equals(object obj) + if (obj is null || GetType() != obj.GetType()) { - if (ReferenceEquals(this, obj)) - { - return true; - } + return false; + } - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + var other = (ResourceFieldChainExpression)obj; - var other = (ResourceFieldChainExpression)obj; + return Fields.SequenceEqual(other.Fields); + } - return Fields.SequenceEqual(other.Fields); - } + public override int GetHashCode() + { + var hashCode = new HashCode(); - public override int GetHashCode() + foreach (ResourceFieldAttribute field in Fields) { - var hashCode = new HashCode(); - - foreach (ResourceFieldAttribute field in Fields) - { - hashCode.Add(field); - } - - return hashCode.ToHashCode(); + hashCode.Add(field); } + + return hashCode.ToHashCode(); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs index d84564ae9b..293c1d9c71 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SortElementExpression.cs @@ -1,81 +1,88 @@ -using System; using System.Text; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents an element in , resulting from text such as: lastName, +/// +/// -lastModifiedAt +/// +/// , or: +/// +/// count(children) +/// +/// . +/// +[PublicAPI] +public class SortElementExpression : QueryExpression { /// - /// Represents an element in . + /// 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. /// - [PublicAPI] - public class SortElementExpression : QueryExpression + public bool IsAscending { get; } + + public SortElementExpression(QueryExpression target, bool isAscending) { - public ResourceFieldChainExpression TargetAttribute { get; } - public CountExpression Count { get; } - public bool IsAscending { get; } + ArgumentNullException.ThrowIfNull(target); - public SortElementExpression(ResourceFieldChainExpression targetAttribute, bool isAscending) - { - ArgumentGuard.NotNull(targetAttribute, nameof(targetAttribute)); + Target = target; + IsAscending = isAscending; + } - TargetAttribute = targetAttribute; - IsAscending = isAscending; - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitSortElement(this, argument); + } - public SortElementExpression(CountExpression count, in bool isAscending) - { - ArgumentGuard.NotNull(count, nameof(count)); + public override string ToString() + { + return InnerToString(false); + } - Count = count; - IsAscending = isAscending; - } + public override string ToFullString() + { + return InnerToString(true); + } - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitSortElement(this, argument); - } + private string InnerToString(bool toFullString) + { + var builder = new StringBuilder(); - public override string ToString() + if (!IsAscending) { - var builder = new StringBuilder(); - - if (!IsAscending) - { - builder.Append('-'); - } - - if (TargetAttribute != null) - { - builder.Append(TargetAttribute); - } - else if (Count != null) - { - builder.Append(Count); - } - - return builder.ToString(); + builder.Append('-'); } - public override bool Equals(object obj) - { - if (ReferenceEquals(this, obj)) - { - return true; - } - - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + builder.Append(toFullString ? Target.ToFullString() : Target); - var other = (SortElementExpression)obj; + return builder.ToString(); + } - return Equals(TargetAttribute, other.TargetAttribute) && Equals(Count, other.Count) && IsAscending == other.IsAscending; + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; } - public override int GetHashCode() + if (obj is null || GetType() != obj.GetType()) { - return HashCode.Combine(TargetAttribute, Count, IsAscending); + return false; } + + var other = (SortElementExpression)obj; + + return Equals(Target, other.Target) && IsAscending == other.IsAscending; + } + + public override int GetHashCode() + { + return HashCode.Combine(Target, IsAscending); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs index 9f9c74b668..9c63e46013 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SortExpression.cs @@ -1,62 +1,71 @@ -using System; -using System.Collections.Generic; -using System.Linq; +using System.Collections.Immutable; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents a sorting, resulting from text such as: +/// +/// lastName,-lastModifiedAt,count(children) +/// +/// . +/// +[PublicAPI] +public class SortExpression : QueryExpression { /// - /// Represents a sorting, resulting from text such as: lastName,-lastModifiedAt + /// One or more elements to sort on. /// - [PublicAPI] - public class SortExpression : QueryExpression + public IImmutableList Elements { get; } + + public SortExpression(IImmutableList elements) { - public IReadOnlyCollection Elements { get; } + ArgumentGuard.NotNullNorEmpty(elements); - public SortExpression(IReadOnlyCollection elements) - { - ArgumentGuard.NotNullNorEmpty(elements, nameof(elements)); + Elements = elements; + } - Elements = elements; - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitSort(this, argument); + } - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitSort(this, argument); - } + public override string ToString() + { + return string.Join(",", Elements.Select(child => child.ToString())); + } - public override string ToString() + public override string ToFullString() + { + return string.Join(",", Elements.Select(child => child.ToFullString())); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) { - return string.Join(",", Elements.Select(child => child.ToString())); + return true; } - public override bool Equals(object obj) + if (obj is null || GetType() != obj.GetType()) { - if (ReferenceEquals(this, obj)) - { - return true; - } + return false; + } - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + var other = (SortExpression)obj; - var other = (SortExpression)obj; + return Elements.SequenceEqual(other.Elements); + } - return Elements.SequenceEqual(other.Elements); - } + public override int GetHashCode() + { + var hashCode = new HashCode(); - public override int GetHashCode() + foreach (SortElementExpression element in Elements) { - var hashCode = new HashCode(); - - foreach (SortElementExpression element in Elements) - { - hashCode.Add(element); - } - - return hashCode.ToHashCode(); + hashCode.Add(element); } + + return hashCode.ToHashCode(); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs index 7511ab309a..e075c3f915 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpression.cs @@ -1,63 +1,72 @@ -using System; -using System.Collections.Generic; -using System.Linq; +using System.Collections.Immutable; using JetBrains.Annotations; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents a sparse fieldset, resulting from text such as: +/// +/// firstName,lastName,articles +/// +/// . +/// +[PublicAPI] +public class SparseFieldSetExpression : QueryExpression { /// - /// Represents a sparse fieldset, resulting from text such as: firstName,lastName,articles + /// The set of JSON:API fields to include. Chain format: a single field. /// - [PublicAPI] - public class SparseFieldSetExpression : QueryExpression + public IImmutableSet Fields { get; } + + public SparseFieldSetExpression(IImmutableSet fields) { - public IReadOnlyCollection Fields { get; } + ArgumentGuard.NotNullNorEmpty(fields); - public SparseFieldSetExpression(IReadOnlyCollection fields) - { - ArgumentGuard.NotNullNorEmpty(fields, nameof(fields)); + Fields = fields; + } - Fields = fields; - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitSparseFieldSet(this, argument); + } - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitSparseFieldSet(this, argument); - } + public override string ToString() + { + return string.Join(",", Fields.Select(field => field.PublicName).OrderBy(name => name)); + } - public override string ToString() + public override string ToFullString() + { + return string.Join(".", Fields.Select(field => $"{field.Type.PublicName}:{field.PublicName}").OrderBy(name => name)); + } + + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) { - return string.Join(",", Fields.Select(child => child.PublicName)); + return true; } - public override bool Equals(object obj) + if (obj is null || GetType() != obj.GetType()) { - if (ReferenceEquals(this, obj)) - { - return true; - } + return false; + } - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + var other = (SparseFieldSetExpression)obj; - var other = (SparseFieldSetExpression)obj; + return Fields.SetEquals(other.Fields); + } - return Fields.SequenceEqual(other.Fields); - } + public override int GetHashCode() + { + var hashCode = new HashCode(); - public override int GetHashCode() + foreach (ResourceFieldAttribute field in Fields) { - var hashCode = new HashCode(); - - foreach (ResourceFieldAttribute field in Fields) - { - hashCode.Add(field); - } - - return hashCode.ToHashCode(); + hashCode.Add(field); } + + return hashCode.ToHashCode(); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs index 62010b7e11..936071ffd1 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldSetExpressionExtensions.cs @@ -1,78 +1,73 @@ -using System; -using System.Collections.Generic; -using System.Linq; +using System.Collections.Immutable; using System.Linq.Expressions; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +[PublicAPI] +public static class SparseFieldSetExpressionExtensions { - [PublicAPI] - public static class SparseFieldSetExpressionExtensions + public static SparseFieldSetExpression? Including(this SparseFieldSetExpression? sparseFieldSet, + Expression> fieldSelector, IResourceGraph resourceGraph) + where TResource : class, IIdentifiable { - public static SparseFieldSetExpression Including(this SparseFieldSetExpression sparseFieldSet, - Expression> fieldSelector, IResourceGraph resourceGraph) - where TResource : class, IIdentifiable - { - ArgumentGuard.NotNull(fieldSelector, nameof(fieldSelector)); - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - - SparseFieldSetExpression newSparseFieldSet = sparseFieldSet; + ArgumentNullException.ThrowIfNull(fieldSelector); + ArgumentNullException.ThrowIfNull(resourceGraph); - foreach (ResourceFieldAttribute field in resourceGraph.GetFields(fieldSelector)) - { - newSparseFieldSet = IncludeField(newSparseFieldSet, field); - } + SparseFieldSetExpression? newSparseFieldSet = sparseFieldSet; - return newSparseFieldSet; + foreach (ResourceFieldAttribute field in resourceGraph.GetFields(fieldSelector)) + { + newSparseFieldSet = IncludeField(newSparseFieldSet, field); } - private static SparseFieldSetExpression IncludeField(SparseFieldSetExpression sparseFieldSet, ResourceFieldAttribute fieldToInclude) - { - if (sparseFieldSet == null || sparseFieldSet.Fields.Contains(fieldToInclude)) - { - return sparseFieldSet; - } + return newSparseFieldSet; + } - HashSet fieldSet = sparseFieldSet.Fields.ToHashSet(); - fieldSet.Add(fieldToInclude); - return new SparseFieldSetExpression(fieldSet); + private static SparseFieldSetExpression? IncludeField(SparseFieldSetExpression? sparseFieldSet, ResourceFieldAttribute fieldToInclude) + { + if (sparseFieldSet == null || sparseFieldSet.Fields.Contains(fieldToInclude)) + { + return sparseFieldSet; } - public static SparseFieldSetExpression Excluding(this SparseFieldSetExpression sparseFieldSet, - Expression> fieldSelector, IResourceGraph resourceGraph) - where TResource : class, IIdentifiable - { - ArgumentGuard.NotNull(fieldSelector, nameof(fieldSelector)); - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + IImmutableSet newSparseFieldSet = sparseFieldSet.Fields.Add(fieldToInclude); + return new SparseFieldSetExpression(newSparseFieldSet); + } - SparseFieldSetExpression newSparseFieldSet = sparseFieldSet; + public static SparseFieldSetExpression? Excluding(this SparseFieldSetExpression? sparseFieldSet, + Expression> fieldSelector, IResourceGraph resourceGraph) + where TResource : class, IIdentifiable + { + ArgumentNullException.ThrowIfNull(fieldSelector); + ArgumentNullException.ThrowIfNull(resourceGraph); - foreach (ResourceFieldAttribute field in resourceGraph.GetFields(fieldSelector)) - { - newSparseFieldSet = ExcludeField(newSparseFieldSet, field); - } + SparseFieldSetExpression? newSparseFieldSet = sparseFieldSet; - return newSparseFieldSet; + foreach (ResourceFieldAttribute field in resourceGraph.GetFields(fieldSelector)) + { + newSparseFieldSet = ExcludeField(newSparseFieldSet, field); } - private static SparseFieldSetExpression ExcludeField(SparseFieldSetExpression sparseFieldSet, ResourceFieldAttribute fieldToExclude) - { - // Design tradeoff: When the sparse fieldset is empty, it means all fields will be selected. - // Adding an exclusion in that case is a no-op, which results in still retrieving the excluded field from data store. - // But later, when serializing the response, the sparse fieldset is first populated with all fields, - // so then the exclusion will actually be applied and the excluded field is not returned to the client. + return newSparseFieldSet; + } - if (sparseFieldSet == null || !sparseFieldSet.Fields.Contains(fieldToExclude)) - { - return sparseFieldSet; - } + private static SparseFieldSetExpression? ExcludeField(SparseFieldSetExpression? sparseFieldSet, ResourceFieldAttribute fieldToExclude) + { + // Design tradeoff: When the sparse fieldset is empty, it means all fields will be selected. + // Adding an exclusion in that case is a no-op, which results in still retrieving the excluded field from data store. + // But later, when serializing the response, the sparse fieldset is first populated with all fields, + // so then the exclusion will actually be applied and the excluded field is not returned to the client. - HashSet fieldSet = sparseFieldSet.Fields.ToHashSet(); - fieldSet.Remove(fieldToExclude); - return new SparseFieldSetExpression(fieldSet); + if (sparseFieldSet == null || !sparseFieldSet.Fields.Contains(fieldToExclude)) + { + return sparseFieldSet; } + + IImmutableSet newSparseFieldSet = sparseFieldSet.Fields.Remove(fieldToExclude); + return new SparseFieldSetExpression(newSparseFieldSet); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs index 4fdc92ec60..fc1e9fb88b 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/SparseFieldTableExpression.cs @@ -1,80 +1,90 @@ -using System; -using System.Collections.Generic; -using System.Linq; +using System.Collections.Immutable; using System.Text; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +/// +/// Represents a lookup table of sparse fieldsets per resource type. +/// +[PublicAPI] +public class SparseFieldTableExpression : QueryExpression { /// - /// Represents a lookup table of sparse fieldsets per resource type. + /// The set of JSON:API fields to include, per resource type. /// - [PublicAPI] - public class SparseFieldTableExpression : QueryExpression - { - public IReadOnlyDictionary Table { get; } + public IImmutableDictionary Table { get; } - public SparseFieldTableExpression(IReadOnlyDictionary table) - { - ArgumentGuard.NotNullNorEmpty(table, nameof(table), "entries"); + public SparseFieldTableExpression(IImmutableDictionary table) + { + ArgumentGuard.NotNullNorEmpty(table); - Table = table; - } + Table = table; + } - public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) - { - return visitor.VisitSparseFieldTable(this, argument); - } + public override TResult Accept(QueryExpressionVisitor visitor, TArgument argument) + { + return visitor.VisitSparseFieldTable(this, argument); + } - public override string ToString() - { - var builder = new StringBuilder(); + public override string ToString() + { + return InnerToString(false); + } - foreach ((ResourceContext resource, SparseFieldSetExpression fields) in Table) - { - if (builder.Length > 0) - { - builder.Append(','); - } - - builder.Append(resource.PublicName); - builder.Append('('); - builder.Append(fields); - builder.Append(')'); - } + public override string ToFullString() + { + return InnerToString(true); + } - return builder.ToString(); - } + private string InnerToString(bool toFullString) + { + var builder = new StringBuilder(); - public override bool Equals(object obj) + foreach ((ResourceType resourceType, SparseFieldSetExpression fieldSet) in Table) { - if (ReferenceEquals(this, obj)) + if (builder.Length > 0) { - return true; + builder.Append(','); } - if (obj is null || GetType() != obj.GetType()) - { - return false; - } + builder.Append(resourceType.PublicName); + builder.Append('('); + builder.Append(toFullString ? fieldSet.ToFullString() : fieldSet); + builder.Append(')'); + } - var other = (SparseFieldTableExpression)obj; + return builder.ToString(); + } - return Table.SequenceEqual(other.Table); + public override bool Equals(object? obj) + { + if (ReferenceEquals(this, obj)) + { + return true; } - public override int GetHashCode() + if (obj is null || GetType() != obj.GetType()) { - var hashCode = new HashCode(); + return false; + } - foreach ((ResourceContext resourceContext, SparseFieldSetExpression sparseFieldSet) in Table) - { - hashCode.Add(resourceContext); - hashCode.Add(sparseFieldSet); - } + var other = (SparseFieldTableExpression)obj; + + return Table.DictionaryEqual(other.Table); + } - return hashCode.ToHashCode(); + public override int GetHashCode() + { + var hashCode = new HashCode(); + + foreach ((ResourceType resourceType, SparseFieldSetExpression sparseFieldSet) in Table) + { + hashCode.Add(resourceType); + hashCode.Add(sparseFieldSet); } + + return hashCode.ToHashCode(); } } diff --git a/src/JsonApiDotNetCore/Queries/Expressions/TextMatchKind.cs b/src/JsonApiDotNetCore/Queries/Expressions/TextMatchKind.cs index e51436b252..e9b28c4b88 100644 --- a/src/JsonApiDotNetCore/Queries/Expressions/TextMatchKind.cs +++ b/src/JsonApiDotNetCore/Queries/Expressions/TextMatchKind.cs @@ -1,9 +1,8 @@ -namespace JsonApiDotNetCore.Queries.Expressions +namespace JsonApiDotNetCore.Queries.Expressions; + +public enum TextMatchKind { - public enum TextMatchKind - { - Contains, - StartsWith, - EndsWith - } + Contains, + StartsWith, + EndsWith } diff --git a/src/JsonApiDotNetCore/Queries/FieldSelection.cs b/src/JsonApiDotNetCore/Queries/FieldSelection.cs new file mode 100644 index 0000000000..b929db2c80 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/FieldSelection.cs @@ -0,0 +1,75 @@ +using System.Text; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries; + +/// +/// Provides access to sparse fieldsets, per resource type. There's usually just a single resource type, but there can be multiple in case an endpoint +/// for an abstract resource type returns derived types. +/// +[PublicAPI] +public sealed class FieldSelection : Dictionary +{ + public bool IsEmpty => Values.All(selectors => selectors.IsEmpty); + + public IReadOnlySet GetResourceTypes() + { + 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 + { + ArgumentNullException.ThrowIfNull(resourceType); + + if (!ContainsKey(resourceType)) + { + this[resourceType] = new FieldSelectors(); + } + + return this[resourceType]; + } + + public override string ToString() + { + var builder = new StringBuilder(); + + var writer = new IndentingStringWriter(builder); + WriteSelection(writer); + + return builder.ToString(); + } + + internal void WriteSelection(IndentingStringWriter writer) + { + using (writer.Indent()) + { + foreach (ResourceType type in GetResourceTypes()) + { + writer.WriteLine($"{nameof(FieldSelectors)}<{type.ClrType.Name}>"); + WriterSelectors(writer, type); + } + } + } + + private void WriterSelectors(IndentingStringWriter writer, ResourceType type) + { + using (writer.Indent()) + { + foreach ((ResourceFieldAttribute field, QueryLayer? nextLayer) in GetOrCreateSelectors(type)) + { + if (nextLayer == null) + { + writer.WriteLine(field.ToString()); + } + else + { + nextLayer.WriteLayer(writer, $"{field.PublicName}: "); + } + } + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/FieldSelectors.cs b/src/JsonApiDotNetCore/Queries/FieldSelectors.cs new file mode 100644 index 0000000000..d9ab815638 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/FieldSelectors.cs @@ -0,0 +1,71 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries; + +/// +/// A data structure that contains which fields (attributes and relationships) to retrieve, or empty to retrieve all. In the case of a relationship, it +/// contains the nested query constraints. +/// +[PublicAPI] +public sealed class FieldSelectors : Dictionary +{ + public bool IsEmpty => Count == 0; + + public bool ContainsReadOnlyAttribute + { + get + { + return this.Any(selector => selector.Key is AttrAttribute attribute && attribute.Property.SetMethod == null); + } + } + + public bool ContainsOnlyRelationships + { + get + { + return Count > 0 && this.All(selector => selector.Key is RelationshipAttribute); + } + } + + public bool ContainsField(ResourceFieldAttribute field) + { + ArgumentNullException.ThrowIfNull(field); + + return ContainsKey(field); + } + + public void IncludeAttribute(AttrAttribute attribute) + { + ArgumentNullException.ThrowIfNull(attribute); + + this[attribute] = null; + } + + public void IncludeAttributes(IEnumerable attributes) + { + ArgumentNullException.ThrowIfNull(attributes); + + foreach (AttrAttribute attribute in attributes) + { + this[attribute] = null; + } + } + + public void IncludeRelationship(RelationshipAttribute relationship, QueryLayer queryLayer) + { + ArgumentNullException.ThrowIfNull(relationship); + ArgumentNullException.ThrowIfNull(queryLayer); + + this[relationship] = queryLayer; + } + + public void RemoveAttributes() + { + while (this.Any(pair => pair.Key is AttrAttribute)) + { + ResourceFieldAttribute field = this.First(pair => pair.Key is AttrAttribute).Key; + Remove(field); + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/IEvaluatedIncludeCache.cs b/src/JsonApiDotNetCore/Queries/IEvaluatedIncludeCache.cs new file mode 100644 index 0000000000..bbc76a8269 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/IEvaluatedIncludeCache.cs @@ -0,0 +1,22 @@ +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; + +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 +/// callbacks. The cache enables the serialization layer to take changes from into +/// account. +/// +public interface IEvaluatedIncludeCache +{ + /// + /// Stores the evaluated inclusion tree for later usage. + /// + void Set(IncludeExpression include); + + /// + /// Gets the evaluated inclusion tree that was stored earlier. + /// + IncludeExpression? Get(); +} diff --git a/src/JsonApiDotNetCore/Queries/IPaginationContext.cs b/src/JsonApiDotNetCore/Queries/IPaginationContext.cs index fb249cfbec..e39b3ca354 100644 --- a/src/JsonApiDotNetCore/Queries/IPaginationContext.cs +++ b/src/JsonApiDotNetCore/Queries/IPaginationContext.cs @@ -1,38 +1,38 @@ using JsonApiDotNetCore.Configuration; -namespace JsonApiDotNetCore.Queries +namespace JsonApiDotNetCore.Queries; + +/// +/// 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 { /// - /// Tracks values used for pagination, which is a combined effort from options, query string parsing and fetching the total number of rows. + /// The value 1, unless overridden from query string or resource definition. Should not be higher than . /// - public interface IPaginationContext - { - /// - /// The value 1, unless specified from query string. Never null. Cannot be higher than options.MaximumPageNumber. - /// - PageNumber PageNumber { get; set; } + 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. - /// - PageSize PageSize { get; set; } + /// + /// 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; } - /// - /// Indicates whether the number of resources on the current page equals the page size. When true, a subsequent page might exist (assuming - /// is unknown). - /// - bool IsPageFull { get; set; } + /// + /// Indicates whether the number of resources on the current page equals the page size. When true, a subsequent page might exist (assuming + /// is unknown). + /// + bool IsPageFull { get; set; } - /// - /// The total number of resources. null when is set to false. - /// - int? TotalResourceCount { get; set; } + /// + /// 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 - /// is null. - /// - int? TotalPageCount { get; } - } + /// + /// 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/IQueryConstraintProvider.cs b/src/JsonApiDotNetCore/Queries/IQueryConstraintProvider.cs index c9a82a7b36..451297a509 100644 --- a/src/JsonApiDotNetCore/Queries/IQueryConstraintProvider.cs +++ b/src/JsonApiDotNetCore/Queries/IQueryConstraintProvider.cs @@ -1,15 +1,12 @@ -using System.Collections.Generic; +namespace JsonApiDotNetCore.Queries; -namespace JsonApiDotNetCore.Queries +/// +/// Provides constraints (such as filters, sorting, pagination, sparse fieldsets and inclusions) to be applied on a data set. +/// +public interface IQueryConstraintProvider { /// - /// Provides constraints (such as filters, sorting, pagination, sparse fieldsets and inclusions) to be applied on a data set. + /// Returns a set of scoped expressions. /// - public interface IQueryConstraintProvider - { - /// - /// Returns a set of scoped expressions. - /// - public IReadOnlyCollection GetConstraints(); - } + public IReadOnlyCollection GetConstraints(); } diff --git a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs index 35094f0e43..8b14590e83 100644 --- a/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs +++ b/src/JsonApiDotNetCore/Queries/IQueryLayerComposer.cs @@ -1,70 +1,65 @@ -using System; -using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Services; -namespace JsonApiDotNetCore.Queries +namespace JsonApiDotNetCore.Queries; + +/// +/// Takes scoped expressions from s and transforms them. +/// +public interface IQueryLayerComposer { /// - /// Takes scoped expressions from s and transforms them. + /// Builds a filter from constraints, used to determine total resource count on a primary collection endpoint. /// - public interface IQueryLayerComposer - { - /// - /// Builds a top-level filter from constraints, used to determine total resource count. - /// - FilterExpression GetTopFilterFromConstraints(ResourceContext resourceContext); + FilterExpression? GetPrimaryFilterFromConstraints(ResourceType primaryResourceType); - /// - /// Collects constraints and builds a out of them, used to retrieve the actual resources. - /// - QueryLayer ComposeFromConstraints(ResourceContext requestResource); + /// + /// Builds a filter from constraints, used to determine total resource count on a secondary collection endpoint. + /// + FilterExpression? GetSecondaryFilterFromConstraints([DisallowNull] TId primaryId, HasManyAttribute hasManyRelationship); - /// - /// Collects constraints and builds a out of them, used to retrieve one resource. - /// - QueryLayer ComposeForGetById(TId id, ResourceContext resourceContext, TopFieldSelection fieldSelection); + /// + /// Collects constraints and builds a out of them, used to retrieve the actual resources. + /// + QueryLayer ComposeFromConstraints(ResourceType requestResourceType); - /// - /// Collects constraints and builds the secondary layer for a relationship endpoint. - /// - QueryLayer ComposeSecondaryLayerForRelationship(ResourceContext secondaryResourceContext); + /// + /// Collects constraints and builds a out of them, used to retrieve one resource. + /// + QueryLayer ComposeForGetById([DisallowNull] TId id, ResourceType primaryResourceType, TopFieldSelection fieldSelection); - /// - /// Wraps a layer for a secondary endpoint into a primary layer, rewriting top-level includes. - /// - QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, ResourceContext primaryResourceContext, TId primaryId, - RelationshipAttribute secondaryRelationship); + /// + /// Collects constraints and builds the secondary layer for a relationship endpoint. + /// + QueryLayer ComposeSecondaryLayerForRelationship(ResourceType secondaryResourceType); - /// - /// 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, ResourceContext primaryResource); + /// + /// Wraps a layer for a secondary endpoint into a primary layer, rewriting top-level includes. + /// + QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, ResourceType primaryResourceType, [DisallowNull] TId primaryId, + RelationshipAttribute relationship); - /// - /// Builds a query for each targeted relationship with a filter to match on its right resource IDs. - /// - IEnumerable<(QueryLayer, RelationshipAttribute)> ComposeForGetTargetedSecondaryResourceIds(IIdentifiable primaryResource); + /// + /// 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([DisallowNull] TId id, ResourceType primaryResourceType); - /// - /// Builds a query for the specified relationship with a filter to match on its right resource IDs. - /// - QueryLayer ComposeForGetRelationshipRightIds(RelationshipAttribute relationship, ICollection rightResourceIds); + /// + /// Builds a query for each targeted relationship with a filter to match on its right resource IDs. + /// + IEnumerable<(QueryLayer, RelationshipAttribute)> ComposeForGetTargetedSecondaryResourceIds(IIdentifiable primaryResource); - /// - /// 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); + /// + /// Builds a query for the specified relationship with a filter to match on its right resource IDs. + /// + QueryLayer ComposeForGetRelationshipRightIds(RelationshipAttribute relationship, ICollection rightResourceIds); - /// - /// Provides access to the request-scoped instance. This method has been added solely to prevent introducing a - /// breaking change in the constructor and will be removed in the next major version. - /// - [Obsolete] - IResourceDefinitionAccessor GetResourceDefinitionAccessor(); - } + /// + /// Builds a query for a to-many relationship with a filter to match on its left and right resource IDs. + /// + QueryLayer ComposeForHasMany(HasManyAttribute hasManyRelationship, [DisallowNull] TId leftId, ICollection rightResourceIds); } diff --git a/src/JsonApiDotNetCore/Queries/ISparseFieldSetCache.cs b/src/JsonApiDotNetCore/Queries/ISparseFieldSetCache.cs new file mode 100644 index 0000000000..22046d3bca --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/ISparseFieldSetCache.cs @@ -0,0 +1,39 @@ +using System.Collections.Immutable; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Queries; + +/// +/// Takes sparse fieldsets from s and invokes +/// on them. +/// +/// +/// This cache ensures that for each request (or operation per request), the resource definition callback is executed only twice per resource type. The +/// first invocation is used to obtain the fields to retrieve from the underlying data store, while the second invocation is used to determine which +/// fields to write to the response body. +/// +public interface ISparseFieldSetCache +{ + /// + /// Gets the set of sparse fields to retrieve from the underlying data store. Returns an empty set to retrieve all fields. + /// + IImmutableSet GetSparseFieldSetForQuery(ResourceType resourceType); + + /// + /// Gets the set of attributes to retrieve from the underlying data store for relationship endpoints. This always returns 'id', along with any additional + /// attributes from resource definition callback. + /// + IImmutableSet GetIdAttributeSetForRelationshipQuery(ResourceType resourceType); + + /// + /// Gets the evaluated set of sparse fields to serialize into the response body. + /// + IImmutableSet GetSparseFieldSetForSerializer(ResourceType resourceType); + + /// + /// Resets the cached results from resource definition callbacks. + /// + void Reset(); +} diff --git a/src/JsonApiDotNetCore/Queries/IndentingStringWriter.cs b/src/JsonApiDotNetCore/Queries/IndentingStringWriter.cs new file mode 100644 index 0000000000..e5f39b6c77 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/IndentingStringWriter.cs @@ -0,0 +1,36 @@ +using System.Text; + +namespace JsonApiDotNetCore.Queries; + +internal sealed class IndentingStringWriter(StringBuilder builder) : IDisposable +{ + private readonly StringBuilder _builder = builder; + + private int _indentDepth; + + public void WriteLine(string? line) + { + if (_indentDepth > 0) + { + _builder.Append(new string(' ', _indentDepth * 2)); + } + + _builder.AppendLine(line); + } + + public IndentingStringWriter Indent() + { + WriteLine("{"); + _indentDepth++; + return this; + } + + public void Dispose() + { + if (_indentDepth > 0) + { + _indentDepth--; + WriteLine("}"); + } + } +} diff --git a/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs b/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs deleted file mode 100644 index 1e9202827b..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/EvaluatedIncludeCache.cs +++ /dev/null @@ -1,22 +0,0 @@ -using JsonApiDotNetCore.Queries.Expressions; - -namespace JsonApiDotNetCore.Queries.Internal -{ - /// - internal sealed class EvaluatedIncludeCache : IEvaluatedIncludeCache - { - private IncludeExpression _include; - - /// - public void Set(IncludeExpression include) - { - _include = include; - } - - /// - public IncludeExpression Get() - { - return _include; - } - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/IEvaluatedIncludeCache.cs b/src/JsonApiDotNetCore/Queries/Internal/IEvaluatedIncludeCache.cs deleted file mode 100644 index d7c924f066..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/IEvaluatedIncludeCache.cs +++ /dev/null @@ -1,23 +0,0 @@ -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCore.Queries.Internal -{ - /// - /// Provides in-memory storage for the evaluated inclusion tree within a request. This tree is produced from query string and resource definition - /// callbacks. The cache enables the serialization layer to take changes from into - /// account. - /// - public interface IEvaluatedIncludeCache - { - /// - /// Stores the evaluated inclusion tree for later usage. - /// - void Set(IncludeExpression include); - - /// - /// Gets the evaluated inclusion tree that was stored earlier. - /// - IncludeExpression Get(); - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainRequirements.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainRequirements.cs deleted file mode 100644 index 5864c18d3e..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FieldChainRequirements.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -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 d4a7f04acd..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/FilterParser.cs +++ /dev/null @@ -1,355 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -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 IResourceContextProvider _resourceContextProvider; - private readonly IResourceFactory _resourceFactory; - private readonly Action _validateSingleFieldCallback; - private ResourceContext _resourceContextInScope; - - public FilterParser(IResourceContextProvider resourceContextProvider, IResourceFactory resourceFactory, - Action validateSingleFieldCallback = null) - : base(resourceContextProvider) - { - ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider)); - ArgumentGuard.NotNull(resourceFactory, nameof(resourceFactory)); - - _resourceContextProvider = resourceContextProvider; - _resourceFactory = resourceFactory; - _validateSingleFieldCallback = validateSingleFieldCallback; - } - - public FilterExpression Parse(string source, ResourceContext resourceContextInScope) - { - ArgumentGuard.NotNull(resourceContextInScope, nameof(resourceContextInScope)); - - _resourceContextInScope = resourceContextInScope; - - 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(); - } - } - } - - 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); - - var terms = new List(); - - FilterExpression term = ParseFilter(); - terms.Add(term); - - EatSingleCharacterToken(TokenKind.Comma); - - term = ParseFilter(); - terms.Add(term); - - while (TokenStack.TryPeek(out Token nextToken) && nextToken.Kind == TokenKind.Comma) - { - EatSingleCharacterToken(TokenKind.Comma); - - term = ParseFilter(); - terms.Add(term); - } - - EatSingleCharacterToken(TokenKind.CloseParen); - - var logicalOperator = Enum.Parse(operatorName.Pascalize()); - return new LogicalExpression(logicalOperator, terms); - } - - 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 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.Last().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 EqualsAnyOfExpression ParseAny() - { - EatText(Keywords.Any); - EatSingleCharacterToken(TokenKind.OpenParen); - - ResourceFieldChainExpression targetAttribute = ParseFieldChain(FieldChainRequirements.EndsInAttribute, null); - - EatSingleCharacterToken(TokenKind.Comma); - - var constants = new List(); - - LiteralConstantExpression constant = ParseConstant(); - constants.Add(constant); - - EatSingleCharacterToken(TokenKind.Comma); - - constant = ParseConstant(); - constants.Add(constant); - - while (TokenStack.TryPeek(out Token nextToken) && nextToken.Kind == TokenKind.Comma) - { - EatSingleCharacterToken(TokenKind.Comma); - - constant = ParseConstant(); - constants.Add(constant); - } - - EatSingleCharacterToken(TokenKind.CloseParen); - - PropertyInfo targetAttributeProperty = targetAttribute.Fields.Last().Property; - - if (targetAttributeProperty.Name == nameof(Identifiable.Id)) - { - for (int index = 0; index < constants.Count; index++) - { - string stringId = constants[index].Value; - string id = DeObfuscateStringId(targetAttributeProperty.ReflectedType, stringId); - constants[index] = new LiteralConstantExpression(id); - } - } - - return new EqualsAnyOfExpression(targetAttribute, constants); - } - - protected CollectionNotEmptyExpression 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.Last()); - } - - EatSingleCharacterToken(TokenKind.CloseParen); - - return new CollectionNotEmptyExpression(targetCollection, filter); - } - - private FilterExpression ParseFilterInHas(HasManyAttribute hasManyRelationship) - { - ResourceContext outerScopeBackup = _resourceContextInScope; - - Type innerResourceType = hasManyRelationship.RightType; - _resourceContextInScope = _resourceContextProvider.GetResourceContext(innerResourceType); - - FilterExpression filter = ParseFilter(); - - _resourceContextInScope = outerScopeBackup; - 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 new NullConstantExpression(); - } - - 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 resourceType, string stringId) - { - IIdentifiable tempResource = _resourceFactory.CreateInstance(resourceType); - tempResource.StringId = stringId; - return tempResource.GetTypedId().ToString(); - } - - protected override IReadOnlyCollection OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) - { - if (chainRequirements == FieldChainRequirements.EndsInToMany) - { - return ChainResolver.ResolveToOneChainEndingInToMany(_resourceContextInScope, path, _validateSingleFieldCallback); - } - - if (chainRequirements == FieldChainRequirements.EndsInAttribute) - { - return ChainResolver.ResolveToOneChainEndingInAttribute(_resourceContextInScope, path, _validateSingleFieldCallback); - } - - if (chainRequirements.HasFlag(FieldChainRequirements.EndsInAttribute) && chainRequirements.HasFlag(FieldChainRequirements.EndsInToOne)) - { - return ChainResolver.ResolveToOneChainEndingInAttributeOrToOne(_resourceContextInScope, path, _validateSingleFieldCallback); - } - - throw new InvalidOperationException($"Unexpected combination of chain requirement flags '{chainRequirements}'."); - } - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs deleted file mode 100644 index e2cc83ceba..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/IncludeParser.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing -{ - [PublicAPI] - public class IncludeParser : QueryExpressionParser - { - private static readonly IncludeChainConverter IncludeChainConverter = new IncludeChainConverter(); - - private readonly Action _validateSingleRelationshipCallback; - private ResourceContext _resourceContextInScope; - - public IncludeParser(IResourceContextProvider resourceContextProvider, - Action validateSingleRelationshipCallback = null) - : base(resourceContextProvider) - { - _validateSingleRelationshipCallback = validateSingleRelationshipCallback; - } - - public IncludeExpression Parse(string source, ResourceContext resourceContextInScope, int? maximumDepth) - { - ArgumentGuard.NotNull(resourceContextInScope, nameof(resourceContextInScope)); - - _resourceContextInScope = resourceContextInScope; - - Tokenize(source); - - IncludeExpression expression = ParseInclude(maximumDepth); - - AssertTokenStackIsEmpty(); - - return expression; - } - - protected IncludeExpression ParseInclude(int? maximumDepth) - { - ResourceFieldChainExpression firstChain = ParseFieldChain(FieldChainRequirements.IsRelationship, "Relationship name expected."); - - List chains = firstChain.AsList(); - - while (TokenStack.Any()) - { - EatSingleCharacterToken(TokenKind.Comma); - - ResourceFieldChainExpression nextChain = ParseFieldChain(FieldChainRequirements.IsRelationship, "Relationship name expected."); - chains.Add(nextChain); - } - - ValidateMaximumIncludeDepth(maximumDepth, chains); - - return IncludeChainConverter.FromRelationshipChains(chains); - } - - private static void ValidateMaximumIncludeDepth(int? maximumDepth, IReadOnlyCollection chains) - { - if (maximumDepth != null) - { - foreach (ResourceFieldChainExpression chain in chains) - { - if (chain.Fields.Count > maximumDepth) - { - string path = string.Join('.', chain.Fields.Select(field => field.PublicName)); - throw new QueryParseException($"Including '{path}' exceeds the maximum inclusion depth of {maximumDepth}."); - } - } - } - } - - protected override IReadOnlyCollection OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) - { - return ChainResolver.ResolveRelationshipChain(_resourceContextInScope, path, _validateSingleRelationshipCallback); - } - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/Keywords.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/Keywords.cs deleted file mode 100644 index 0bf32ff17e..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/Keywords.cs +++ /dev/null @@ -1,27 +0,0 @@ -using JetBrains.Annotations; - -#pragma warning disable AV1008 // Class should not be static -#pragma warning disable AV1010 // Member hides inherited member - -namespace JsonApiDotNetCore.Queries.Internal.Parsing -{ - [PublicAPI] - public static class Keywords - { - public const string Null = "null"; - public const string Not = "not"; - public const string And = "and"; - public const string Or = "or"; - public new const string Equals = "equals"; - public const string GreaterThan = "greaterThan"; - public const string GreaterOrEqual = "greaterOrEqual"; - public const string LessThan = "lessThan"; - public const string LessOrEqual = "lessOrEqual"; - public const string Contains = "contains"; - public const string StartsWith = "startsWith"; - public const string EndsWith = "endsWith"; - public const string Any = "any"; - public const string Count = "count"; - public const string Has = "has"; - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/PaginationParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/PaginationParser.cs deleted file mode 100644 index 156c5281dc..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/PaginationParser.cs +++ /dev/null @@ -1,113 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -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 ResourceContext _resourceContextInScope; - - public PaginationParser(IResourceContextProvider resourceContextProvider, - Action validateSingleFieldCallback = null) - : base(resourceContextProvider) - { - _validateSingleFieldCallback = validateSingleFieldCallback; - } - - public PaginationQueryStringValueExpression Parse(string source, ResourceContext resourceContextInScope) - { - ArgumentGuard.NotNull(resourceContextInScope, nameof(resourceContextInScope)); - - _resourceContextInScope = resourceContextInScope; - - Tokenize(source); - - PaginationQueryStringValueExpression expression = ParsePagination(); - - AssertTokenStackIsEmpty(); - - return expression; - } - - protected PaginationQueryStringValueExpression ParsePagination() - { - var elements = new List(); - - PaginationElementQueryStringValueExpression element = ParsePaginationElement(); - elements.Add(element); - - while (TokenStack.Any()) - { - EatSingleCharacterToken(TokenKind.Comma); - - element = ParsePaginationElement(); - elements.Add(element); - } - - return new PaginationQueryStringValueExpression(elements); - } - - 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 IReadOnlyCollection OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) - { - return ChainResolver.ResolveToManyChain(_resourceContextInScope, 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 48fcb44a07..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryExpressionParser.cs +++ /dev/null @@ -1,97 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -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; } - private protected ResourceFieldChainResolver ChainResolver { get; } - - protected QueryExpressionParser(IResourceContextProvider resourceContextProvider) - { - ChainResolver = new ResourceFieldChainResolver(resourceContextProvider); - } - - /// - /// Takes a dotted path and walks the resource graph to produce a chain of fields. - /// - protected abstract IReadOnlyCollection 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) - { - if (TokenStack.TryPop(out Token token) && token.Kind == TokenKind.Text) - { - IReadOnlyCollection chain = OnResolveFieldChain(token.Value, chainRequirements); - - if (chain.Any()) - { - return new ResourceFieldChainExpression(chain); - } - } - - 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 26010e3528..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryParseException.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; -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 4944f090b1..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryStringParameterScopeParser.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System; -using System.Collections.Generic; -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 ResourceContext _resourceContextInScope; - - public QueryStringParameterScopeParser(IResourceContextProvider resourceContextProvider, FieldChainRequirements chainRequirements, - Action validateSingleFieldCallback = null) - : base(resourceContextProvider) - { - _chainRequirements = chainRequirements; - _validateSingleFieldCallback = validateSingleFieldCallback; - } - - public QueryStringParameterScopeExpression Parse(string source, ResourceContext resourceContextInScope) - { - ArgumentGuard.NotNull(resourceContextInScope, nameof(resourceContextInScope)); - - _resourceContextInScope = resourceContextInScope; - - 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 IReadOnlyCollection 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(_resourceContextInScope, path, _validateSingleFieldCallback); - } - - if (chainRequirements == FieldChainRequirements.IsRelationship) - { - return ChainResolver.ResolveRelationshipChain(_resourceContextInScope, path, _validateSingleFieldCallback); - } - - throw new InvalidOperationException($"Unexpected combination of chain requirement flags '{chainRequirements}'."); - } - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs deleted file mode 100644 index 04feabd60d..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/QueryTokenizer.cs +++ /dev/null @@ -1,142 +0,0 @@ -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Text; -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing -{ - [PublicAPI] - public sealed class QueryTokenizer - { - public static readonly IReadOnlyDictionary SingleCharacterToTokenKinds = new ReadOnlyDictionary( - new Dictionary - { - ['('] = TokenKind.OpenParen, - [')'] = TokenKind.CloseParen, - ['['] = TokenKind.OpenBracket, - [']'] = TokenKind.CloseBracket, - [','] = TokenKind.Comma, - [':'] = TokenKind.Colon, - ['-'] = TokenKind.Minus - }); - - private readonly string _source; - private readonly StringBuilder _textBuffer = new StringBuilder(); - private int _offset; - private bool _isInQuotedSection; - - public QueryTokenizer(string source) - { - ArgumentGuard.NotNull(source, nameof(source)); - - _source = source; - } - - public IEnumerable EnumerateTokens() - { - _textBuffer.Clear(); - _isInQuotedSection = false; - _offset = 0; - - while (_offset < _source.Length) - { - char ch = _source[_offset]; - - if (ch == '\'') - { - if (_isInQuotedSection) - { - char? peeked = PeekChar(); - - if (peeked == '\'') - { - _textBuffer.Append(ch); - _offset += 2; - continue; - } - - _isInQuotedSection = false; - - Token literalToken = ProduceTokenFromTextBuffer(true); - yield return literalToken; - } - else - { - if (_textBuffer.Length > 0) - { - throw new QueryParseException("Unexpected ' outside text."); - } - - _isInQuotedSection = true; - } - } - else - { - TokenKind? singleCharacterTokenKind = _isInQuotedSection ? null : TryGetSingleCharacterTokenKind(ch); - - if (singleCharacterTokenKind != null && !IsMinusInsideText(singleCharacterTokenKind.Value)) - { - Token identifierToken = ProduceTokenFromTextBuffer(false); - - if (identifierToken != null) - { - yield return identifierToken; - } - - yield return new Token(singleCharacterTokenKind.Value); - } - else - { - if (_textBuffer.Length == 0 && ch == ' ') - { - throw new QueryParseException("Unexpected whitespace."); - } - - _textBuffer.Append(ch); - } - } - - _offset++; - } - - if (_isInQuotedSection) - { - throw new QueryParseException("' expected."); - } - - Token lastToken = ProduceTokenFromTextBuffer(false); - - if (lastToken != null) - { - yield return lastToken; - } - } - - private bool IsMinusInsideText(TokenKind kind) - { - return kind == TokenKind.Minus && _textBuffer.Length > 0; - } - - private char? PeekChar() - { - return _offset + 1 < _source.Length ? (char?)_source[_offset + 1] : null; - } - - private static TokenKind? TryGetSingleCharacterTokenKind(char ch) - { - return SingleCharacterToTokenKinds.ContainsKey(ch) ? (TokenKind?)SingleCharacterToTokenKinds[ch] : null; - } - - private Token ProduceTokenFromTextBuffer(bool isQuotedText) - { - if (isQuotedText || _textBuffer.Length > 0) - { - string text = _textBuffer.ToString(); - _textBuffer.Clear(); - return new Token(isQuotedText ? TokenKind.QuotedText : TokenKind.Text, text); - } - - return null; - } - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs deleted file mode 100644 index 029c76fcb1..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/ResourceFieldChainResolver.cs +++ /dev/null @@ -1,268 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -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 readonly IResourceContextProvider _resourceContextProvider; - - public ResourceFieldChainResolver(IResourceContextProvider resourceContextProvider) - { - ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider)); - - _resourceContextProvider = resourceContextProvider; - } - - /// - /// Resolves a chain of relationships that ends in a to-many relationship, for example: blogs.owner.articles.comments - /// - public IReadOnlyCollection ResolveToManyChain(ResourceContext resourceContext, string path, - Action validateCallback = null) - { - var chain = new List(); - - string[] publicNameParts = path.Split("."); - ResourceContext nextResourceContext = resourceContext; - - foreach (string publicName in publicNameParts[..^1]) - { - RelationshipAttribute relationship = GetRelationship(publicName, nextResourceContext, path); - - validateCallback?.Invoke(relationship, nextResourceContext, path); - - chain.Add(relationship); - nextResourceContext = _resourceContextProvider.GetResourceContext(relationship.RightType); - } - - string lastName = publicNameParts[^1]; - RelationshipAttribute lastToManyRelationship = GetToManyRelationship(lastName, nextResourceContext, path); - - validateCallback?.Invoke(lastToManyRelationship, nextResourceContext, path); - - chain.Add(lastToManyRelationship); - return chain; - } - - /// - /// Resolves a chain of relationships. - /// - /// blogs.articles.comments - /// - /// - /// author.address - /// - /// - /// articles.revisions.author - /// - /// - public IReadOnlyCollection ResolveRelationshipChain(ResourceContext resourceContext, string path, - Action validateCallback = null) - { - var chain = new List(); - ResourceContext nextResourceContext = resourceContext; - - foreach (string publicName in path.Split(".")) - { - RelationshipAttribute relationship = GetRelationship(publicName, nextResourceContext, path); - - validateCallback?.Invoke(relationship, nextResourceContext, path); - - chain.Add(relationship); - nextResourceContext = _resourceContextProvider.GetResourceContext(relationship.RightType); - } - - return chain; - } - - /// - /// Resolves a chain of to-one relationships that ends in an attribute. - /// - /// author.address.country.name - /// - /// name - /// - public IReadOnlyCollection ResolveToOneChainEndingInAttribute(ResourceContext resourceContext, string path, - Action validateCallback = null) - { - var chain = new List(); - - string[] publicNameParts = path.Split("."); - ResourceContext nextResourceContext = resourceContext; - - foreach (string publicName in publicNameParts[..^1]) - { - RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceContext, path); - - validateCallback?.Invoke(toOneRelationship, nextResourceContext, path); - - chain.Add(toOneRelationship); - nextResourceContext = _resourceContextProvider.GetResourceContext(toOneRelationship.RightType); - } - - string lastName = publicNameParts[^1]; - AttrAttribute lastAttribute = GetAttribute(lastName, nextResourceContext, path); - - validateCallback?.Invoke(lastAttribute, nextResourceContext, path); - - chain.Add(lastAttribute); - return chain; - } - - /// - /// Resolves a chain of to-one relationships that ends in a to-many relationship. - /// - /// article.comments - /// - /// - /// comments - /// - /// - public IReadOnlyCollection ResolveToOneChainEndingInToMany(ResourceContext resourceContext, string path, - Action validateCallback = null) - { - var chain = new List(); - - string[] publicNameParts = path.Split("."); - ResourceContext nextResourceContext = resourceContext; - - foreach (string publicName in publicNameParts[..^1]) - { - RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceContext, path); - - validateCallback?.Invoke(toOneRelationship, nextResourceContext, path); - - chain.Add(toOneRelationship); - nextResourceContext = _resourceContextProvider.GetResourceContext(toOneRelationship.RightType); - } - - string lastName = publicNameParts[^1]; - - RelationshipAttribute toManyRelationship = GetToManyRelationship(lastName, nextResourceContext, path); - - validateCallback?.Invoke(toManyRelationship, nextResourceContext, path); - - chain.Add(toManyRelationship); - return chain; - } - - /// - /// 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 IReadOnlyCollection ResolveToOneChainEndingInAttributeOrToOne(ResourceContext resourceContext, string path, - Action validateCallback = null) - { - var chain = new List(); - - string[] publicNameParts = path.Split("."); - ResourceContext nextResourceContext = resourceContext; - - foreach (string publicName in publicNameParts[..^1]) - { - RelationshipAttribute toOneRelationship = GetToOneRelationship(publicName, nextResourceContext, path); - - validateCallback?.Invoke(toOneRelationship, nextResourceContext, path); - - chain.Add(toOneRelationship); - nextResourceContext = _resourceContextProvider.GetResourceContext(toOneRelationship.RightType); - } - - string lastName = publicNameParts[^1]; - ResourceFieldAttribute lastField = GetField(lastName, nextResourceContext, path); - - if (lastField is HasManyAttribute) - { - throw new QueryParseException(path == lastName - ? $"Field '{lastName}' must be an attribute or a to-one relationship on resource '{nextResourceContext.PublicName}'." - : $"Field '{lastName}' in '{path}' must be an attribute or a to-one relationship on resource '{nextResourceContext.PublicName}'."); - } - - validateCallback?.Invoke(lastField, nextResourceContext, path); - - chain.Add(lastField); - return chain; - } - - private RelationshipAttribute GetRelationship(string publicName, ResourceContext resourceContext, string path) - { - RelationshipAttribute relationship = resourceContext.Relationships.FirstOrDefault(nextRelationship => nextRelationship.PublicName == publicName); - - if (relationship == null) - { - throw new QueryParseException(path == publicName - ? $"Relationship '{publicName}' does not exist on resource '{resourceContext.PublicName}'." - : $"Relationship '{publicName}' in '{path}' does not exist on resource '{resourceContext.PublicName}'."); - } - - return relationship; - } - - private RelationshipAttribute GetToManyRelationship(string publicName, ResourceContext resourceContext, string path) - { - RelationshipAttribute relationship = GetRelationship(publicName, resourceContext, path); - - if (!(relationship is HasManyAttribute)) - { - throw new QueryParseException(path == publicName - ? $"Relationship '{publicName}' must be a to-many relationship on resource '{resourceContext.PublicName}'." - : $"Relationship '{publicName}' in '{path}' must be a to-many relationship on resource '{resourceContext.PublicName}'."); - } - - return relationship; - } - - private RelationshipAttribute GetToOneRelationship(string publicName, ResourceContext resourceContext, string path) - { - RelationshipAttribute relationship = GetRelationship(publicName, resourceContext, path); - - if (!(relationship is HasOneAttribute)) - { - throw new QueryParseException(path == publicName - ? $"Relationship '{publicName}' must be a to-one relationship on resource '{resourceContext.PublicName}'." - : $"Relationship '{publicName}' in '{path}' must be a to-one relationship on resource '{resourceContext.PublicName}'."); - } - - return relationship; - } - - private AttrAttribute GetAttribute(string publicName, ResourceContext resourceContext, string path) - { - AttrAttribute attribute = resourceContext.Attributes.FirstOrDefault(nextAttribute => nextAttribute.PublicName == publicName); - - if (attribute == null) - { - throw new QueryParseException(path == publicName - ? $"Attribute '{publicName}' does not exist on resource '{resourceContext.PublicName}'." - : $"Attribute '{publicName}' in '{path}' does not exist on resource '{resourceContext.PublicName}'."); - } - - return attribute; - } - - public ResourceFieldAttribute GetField(string publicName, ResourceContext resourceContext, string path) - { - ResourceFieldAttribute field = resourceContext.Fields.FirstOrDefault(nextField => nextField.PublicName == publicName); - - if (field == null) - { - throw new QueryParseException(path == publicName - ? $"Field '{publicName}' does not exist on resource '{resourceContext.PublicName}'." - : $"Field '{publicName}' in '{path}' does not exist on resource '{resourceContext.PublicName}'."); - } - - 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 3b146b1785..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SortParser.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -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 ResourceContext _resourceContextInScope; - - public SortParser(IResourceContextProvider resourceContextProvider, - Action validateSingleFieldCallback = null) - : base(resourceContextProvider) - { - _validateSingleFieldCallback = validateSingleFieldCallback; - } - - public SortExpression Parse(string source, ResourceContext resourceContextInScope) - { - ArgumentGuard.NotNull(resourceContextInScope, nameof(resourceContextInScope)); - - _resourceContextInScope = resourceContextInScope; - - Tokenize(source); - - SortExpression expression = ParseSort(); - - AssertTokenStackIsEmpty(); - - return expression; - } - - protected SortExpression ParseSort() - { - SortElementExpression firstElement = ParseSortElement(); - - List elements = firstElement.AsList(); - - while (TokenStack.Any()) - { - EatSingleCharacterToken(TokenKind.Comma); - - SortElementExpression nextElement = ParseSortElement(); - elements.Add(nextElement); - } - - return new SortExpression(elements); - } - - 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 IReadOnlyCollection OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) - { - if (chainRequirements == FieldChainRequirements.EndsInToMany) - { - return ChainResolver.ResolveToOneChainEndingInToMany(_resourceContextInScope, path); - } - - if (chainRequirements == FieldChainRequirements.EndsInAttribute) - { - return ChainResolver.ResolveToOneChainEndingInAttribute(_resourceContextInScope, path, _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 7ce7168a0d..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldSetParser.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -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 ResourceContext _resourceContext; - - public SparseFieldSetParser(IResourceContextProvider resourceContextProvider, - Action validateSingleFieldCallback = null) - : base(resourceContextProvider) - { - _validateSingleFieldCallback = validateSingleFieldCallback; - } - - public SparseFieldSetExpression Parse(string source, ResourceContext resourceContext) - { - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); - - _resourceContext = resourceContext; - - Tokenize(source); - - SparseFieldSetExpression expression = ParseSparseFieldSet(); - - AssertTokenStackIsEmpty(); - - return expression; - } - - protected SparseFieldSetExpression ParseSparseFieldSet() - { - var fields = new Dictionary(); - - while (TokenStack.Any()) - { - if (fields.Count > 0) - { - EatSingleCharacterToken(TokenKind.Comma); - } - - ResourceFieldChainExpression nextChain = ParseFieldChain(FieldChainRequirements.EndsInAttribute, "Field name expected."); - ResourceFieldAttribute nextField = nextChain.Fields.Single(); - fields[nextField.PublicName] = nextField; - } - - return fields.Any() ? new SparseFieldSetExpression(fields.Values) : null; - } - - protected override IReadOnlyCollection OnResolveFieldChain(string path, FieldChainRequirements chainRequirements) - { - ResourceFieldAttribute field = ChainResolver.GetField(path, _resourceContext, path); - - _validateSingleFieldCallback?.Invoke(field, _resourceContext, path); - - return field.AsArray(); - } - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs deleted file mode 100644 index 361710e345..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/SparseFieldTypeParser.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System; -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.Parsing -{ - [PublicAPI] - public class SparseFieldTypeParser : QueryExpressionParser - { - private readonly IResourceContextProvider _resourceContextProvider; - - public SparseFieldTypeParser(IResourceContextProvider resourceContextProvider) - : base(resourceContextProvider) - { - _resourceContextProvider = resourceContextProvider; - } - - public ResourceContext Parse(string source) - { - Tokenize(source); - - ResourceContext resourceContext = ParseSparseFieldTarget(); - - AssertTokenStackIsEmpty(); - - return resourceContext; - } - - private ResourceContext ParseSparseFieldTarget() - { - if (!TokenStack.TryPop(out Token token) || token.Kind != TokenKind.Text) - { - throw new QueryParseException("Parameter name expected."); - } - - EatSingleCharacterToken(TokenKind.OpenBracket); - - ResourceContext resourceContext = ParseResourceName(); - - EatSingleCharacterToken(TokenKind.CloseBracket); - - return resourceContext; - } - - private ResourceContext ParseResourceName() - { - if (TokenStack.TryPop(out Token token) && token.Kind == TokenKind.Text) - { - return GetResourceContext(token.Value); - } - - throw new QueryParseException("Resource type expected."); - } - - private ResourceContext GetResourceContext(string resourceName) - { - ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(resourceName); - - if (resourceContext == null) - { - throw new QueryParseException($"Resource type '{resourceName}' does not exist."); - } - - return resourceContext; - } - - protected override IReadOnlyCollection 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 c8c8623a67..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/Token.cs +++ /dev/null @@ -1,22 +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, string value = null) - { - Kind = kind; - Value = value; - } - - public override string ToString() - { - return Value == null ? Kind.ToString() : $"{Kind}: {Value}"; - } - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/Parsing/TokenKind.cs b/src/JsonApiDotNetCore/Queries/Internal/Parsing/TokenKind.cs deleted file mode 100644 index 3658c82f18..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/Parsing/TokenKind.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace JsonApiDotNetCore.Queries.Internal.Parsing -{ - public enum TokenKind - { - OpenParen, - CloseParen, - OpenBracket, - CloseBracket, - Comma, - Colon, - Minus, - Text, - QuotedText - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs deleted file mode 100644 index 981ea9d71d..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryLayerComposer.cs +++ /dev/null @@ -1,506 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -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 CollectionConverter(); - private readonly IEnumerable _constraintProviders; - private readonly IResourceContextProvider _resourceContextProvider; - private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; - private readonly IJsonApiOptions _options; - private readonly IPaginationContext _paginationContext; - private readonly ITargetedFields _targetedFields; - private readonly IEvaluatedIncludeCache _evaluatedIncludeCache; - private readonly SparseFieldSetCache _sparseFieldSetCache; - - public QueryLayerComposer(IEnumerable constraintProviders, IResourceContextProvider resourceContextProvider, - IResourceDefinitionAccessor resourceDefinitionAccessor, IJsonApiOptions options, IPaginationContext paginationContext, - ITargetedFields targetedFields, IEvaluatedIncludeCache evaluatedIncludeCache) - { - ArgumentGuard.NotNull(constraintProviders, nameof(constraintProviders)); - ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider)); - 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)); - - _constraintProviders = constraintProviders; - _resourceContextProvider = resourceContextProvider; - _resourceDefinitionAccessor = resourceDefinitionAccessor; - _options = options; - _paginationContext = paginationContext; - _targetedFields = targetedFields; - _evaluatedIncludeCache = evaluatedIncludeCache; - _sparseFieldSetCache = new SparseFieldSetCache(_constraintProviders, resourceDefinitionAccessor); - } - - /// - public FilterExpression GetTopFilterFromConstraints(ResourceContext resourceContext) - { - 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, resourceContext); - } - - /// - public QueryLayer ComposeFromConstraints(ResourceContext requestResource) - { - ArgumentGuard.NotNull(requestResource, nameof(requestResource)); - - ExpressionInScope[] constraints = _constraintProviders.SelectMany(provider => provider.GetConstraints()).ToArray(); - - QueryLayer topLayer = ComposeTopLayer(constraints, requestResource); - topLayer.Include = ComposeChildren(topLayer, constraints); - - _evaluatedIncludeCache.Set(topLayer.Include); - - return topLayer; - } - - private QueryLayer ComposeTopLayer(IEnumerable constraints, ResourceContext resourceContext) - { - // @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, resourceContext); - - if (topPagination != null) - { - _paginationContext.PageSize = topPagination.PageSize; - _paginationContext.PageNumber = topPagination.PageNumber; - } - - return new QueryLayer(resourceContext) - { - Filter = GetFilter(expressionsInTopScope, resourceContext), - Sort = GetSort(expressionsInTopScope, resourceContext), - Pagination = ((JsonApiOptions)_options).DisableTopPagination ? null : topPagination, - Projection = GetProjectionForSparseAttributeSet(resourceContext) - }; - } - - private IncludeExpression ComposeChildren(QueryLayer topLayer, ICollection constraints) - { - // @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 - - IReadOnlyCollection includeElements = - ProcessIncludeSet(include.Elements, topLayer, new List(), constraints); - - return !ReferenceEquals(includeElements, include.Elements) - ? includeElements.Any() ? new IncludeExpression(includeElements) : IncludeExpression.Empty - : include; - } - - private IReadOnlyCollection ProcessIncludeSet(IReadOnlyCollection includeElements, - QueryLayer parentLayer, ICollection parentRelationshipChain, ICollection constraints) - { - IReadOnlyCollection includeElementsEvaluated = - GetIncludeElements(includeElements, parentLayer.ResourceContext) ?? Array.Empty(); - - var updatesInChildren = new Dictionary>(); - - foreach (IncludeElementExpression includeElement in includeElementsEvaluated) - { - parentLayer.Projection ??= new Dictionary(); - - if (!parentLayer.Projection.ContainsKey(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 - - ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(includeElement.Relationship.RightType); - bool isToManyRelationship = includeElement.Relationship is HasManyAttribute; - - var child = new QueryLayer(resourceContext) - { - Filter = isToManyRelationship ? GetFilter(expressionsInCurrentScope, resourceContext) : null, - Sort = isToManyRelationship ? GetSort(expressionsInCurrentScope, resourceContext) : null, - Pagination = isToManyRelationship - ? ((JsonApiOptions)_options).DisableChildrenPagination ? null : GetPagination(expressionsInCurrentScope, resourceContext) - : null, - Projection = GetProjectionForSparseAttributeSet(resourceContext) - }; - - parentLayer.Projection.Add(includeElement.Relationship, child); - - if (includeElement.Children.Any()) - { - IReadOnlyCollection 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 IReadOnlyCollection ApplyIncludeElementUpdates(IEnumerable includeElements, - IDictionary> updatesInChildren) - { - List newIncludeElements = includeElements.ToList(); - - foreach ((IncludeElementExpression existingElement, IReadOnlyCollection updatedChildren) in updatesInChildren) - { - int existingIndex = newIncludeElements.IndexOf(existingElement); - newIncludeElements[existingIndex] = new IncludeElementExpression(existingElement.Relationship, updatedChildren); - } - - return newIncludeElements; - } - - /// - public QueryLayer ComposeForGetById(TId id, ResourceContext resourceContext, TopFieldSelection fieldSelection) - { - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); - - AttrAttribute idAttribute = GetIdAttribute(resourceContext); - - QueryLayer queryLayer = ComposeFromConstraints(resourceContext); - queryLayer.Sort = null; - queryLayer.Pagination = null; - queryLayer.Filter = CreateFilterByIds(id.AsArray(), idAttribute, queryLayer.Filter); - - if (fieldSelection == TopFieldSelection.OnlyIdAttribute) - { - queryLayer.Projection = new Dictionary - { - [idAttribute] = null - }; - } - else if (fieldSelection == TopFieldSelection.WithAllAttributes && queryLayer.Projection != null) - { - // Discard any top-level ?fields[]= or attribute exclusions from resource definition, because we need the full database row. - while (queryLayer.Projection.Any(pair => pair.Key is AttrAttribute)) - { - queryLayer.Projection.Remove(queryLayer.Projection.First(pair => pair.Key is AttrAttribute)); - } - } - - return queryLayer; - } - - /// - public QueryLayer ComposeSecondaryLayerForRelationship(ResourceContext secondaryResourceContext) - { - ArgumentGuard.NotNull(secondaryResourceContext, nameof(secondaryResourceContext)); - - QueryLayer secondaryLayer = ComposeFromConstraints(secondaryResourceContext); - secondaryLayer.Projection = GetProjectionForRelationship(secondaryResourceContext); - secondaryLayer.Include = null; - - return secondaryLayer; - } - - private IDictionary GetProjectionForRelationship(ResourceContext secondaryResourceContext) - { - IReadOnlyCollection secondaryAttributeSet = _sparseFieldSetCache.GetIdAttributeSetForRelationshipQuery(secondaryResourceContext); - - return secondaryAttributeSet.ToDictionary(key => (ResourceFieldAttribute)key, _ => (QueryLayer)null); - } - - /// - public QueryLayer WrapLayerForSecondaryEndpoint(QueryLayer secondaryLayer, ResourceContext primaryResourceContext, TId primaryId, - RelationshipAttribute secondaryRelationship) - { - ArgumentGuard.NotNull(secondaryLayer, nameof(secondaryLayer)); - ArgumentGuard.NotNull(primaryResourceContext, nameof(primaryResourceContext)); - ArgumentGuard.NotNull(secondaryRelationship, nameof(secondaryRelationship)); - - IncludeExpression innerInclude = secondaryLayer.Include; - secondaryLayer.Include = null; - - IReadOnlyCollection primaryAttributeSet = _sparseFieldSetCache.GetIdAttributeSetForRelationshipQuery(primaryResourceContext); - - Dictionary primaryProjection = - primaryAttributeSet.ToDictionary(key => (ResourceFieldAttribute)key, _ => (QueryLayer)null); - - primaryProjection[secondaryRelationship] = secondaryLayer; - - FilterExpression primaryFilter = GetFilter(Array.Empty(), primaryResourceContext); - AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResourceContext); - - return new QueryLayer(primaryResourceContext) - { - Include = RewriteIncludeForSecondaryEndpoint(innerInclude, secondaryRelationship), - Filter = CreateFilterByIds(primaryId.AsArray(), primaryIdAttribute, primaryFilter), - Projection = primaryProjection - }; - } - - private IncludeExpression RewriteIncludeForSecondaryEndpoint(IncludeExpression relativeInclude, RelationshipAttribute secondaryRelationship) - { - IncludeElementExpression parentElement = relativeInclude != null - ? new IncludeElementExpression(secondaryRelationship, relativeInclude.Elements) - : new IncludeElementExpression(secondaryRelationship); - - return new IncludeExpression(parentElement.AsArray()); - } - - private FilterExpression CreateFilterByIds(ICollection 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) - { - List constants = ids.Select(id => new LiteralConstantExpression(id.ToString())).ToList(); - filter = new EqualsAnyOfExpression(idChain, constants); - } - - // @formatter:keep_existing_linebreaks true - - return filter == null - ? existingFilter - : existingFilter == null - ? filter - : new LogicalExpression(LogicalOperator.And, ArrayFactory.Create(filter, existingFilter)); - - // @formatter:keep_existing_linebreaks restore - } - - /// - public QueryLayer ComposeForUpdate(TId id, ResourceContext primaryResource) - { - ArgumentGuard.NotNull(primaryResource, nameof(primaryResource)); - - IncludeElementExpression[] includeElements = _targetedFields.Relationships - .Select(relationship => new IncludeElementExpression(relationship)).ToArray(); - - AttrAttribute primaryIdAttribute = GetIdAttribute(primaryResource); - - QueryLayer primaryLayer = ComposeTopLayer(Array.Empty(), primaryResource); - primaryLayer.Include = includeElements.Any() ? new IncludeExpression(includeElements) : IncludeExpression.Empty; - primaryLayer.Sort = null; - primaryLayer.Pagination = null; - primaryLayer.Filter = CreateFilterByIds(id.AsArray(), primaryIdAttribute, primaryLayer.Filter); - primaryLayer.Projection = 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); - ICollection rightResourceIds = _collectionConverter.ExtractResources(rightValue); - - 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)); - - ResourceContext rightResourceContext = _resourceContextProvider.GetResourceContext(relationship.RightType); - AttrAttribute rightIdAttribute = GetIdAttribute(rightResourceContext); - - object[] typedIds = rightResourceIds.Select(resource => resource.GetTypedId()).ToArray(); - - FilterExpression baseFilter = GetFilter(Array.Empty(), rightResourceContext); - FilterExpression filter = CreateFilterByIds(typedIds, rightIdAttribute, baseFilter); - - return new QueryLayer(rightResourceContext) - { - Include = IncludeExpression.Empty, - Filter = filter, - Projection = new Dictionary - { - [rightIdAttribute] = null - } - }; - } - - /// - public QueryLayer ComposeForHasMany(HasManyAttribute hasManyRelationship, TId leftId, ICollection rightResourceIds) - { - ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship)); - ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); - - ResourceContext leftResourceContext = _resourceContextProvider.GetResourceContext(hasManyRelationship.LeftType); - AttrAttribute leftIdAttribute = GetIdAttribute(leftResourceContext); - - ResourceContext rightResourceContext = _resourceContextProvider.GetResourceContext(hasManyRelationship.RightType); - AttrAttribute rightIdAttribute = GetIdAttribute(rightResourceContext); - object[] rightTypedIds = rightResourceIds.Select(resource => resource.GetTypedId()).ToArray(); - - FilterExpression leftFilter = CreateFilterByIds(leftId.AsArray(), leftIdAttribute, null); - FilterExpression rightFilter = CreateFilterByIds(rightTypedIds, rightIdAttribute, null); - - return new QueryLayer(leftResourceContext) - { - Include = new IncludeExpression(new IncludeElementExpression(hasManyRelationship).AsArray()), - Filter = leftFilter, - Projection = new Dictionary - { - [hasManyRelationship] = new QueryLayer(rightResourceContext) - { - Filter = rightFilter, - Projection = new Dictionary - { - [rightIdAttribute] = null - } - }, - [leftIdAttribute] = null - } - }; - } - - /// - public IResourceDefinitionAccessor GetResourceDefinitionAccessor() - { - return _resourceDefinitionAccessor; - } - - protected virtual IReadOnlyCollection GetIncludeElements(IReadOnlyCollection includeElements, - ResourceContext resourceContext) - { - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); - - return _resourceDefinitionAccessor.OnApplyIncludes(resourceContext.ResourceType, includeElements); - } - - protected virtual FilterExpression GetFilter(IReadOnlyCollection expressionsInScope, ResourceContext resourceContext) - { - ArgumentGuard.NotNull(expressionsInScope, nameof(expressionsInScope)); - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); - - FilterExpression[] filters = expressionsInScope.OfType().ToArray(); - - FilterExpression filter = filters.Length > 1 ? new LogicalExpression(LogicalOperator.And, filters) : filters.FirstOrDefault(); - - return _resourceDefinitionAccessor.OnApplyFilter(resourceContext.ResourceType, filter); - } - - protected virtual SortExpression GetSort(IReadOnlyCollection expressionsInScope, ResourceContext resourceContext) - { - ArgumentGuard.NotNull(expressionsInScope, nameof(expressionsInScope)); - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); - - SortExpression sort = expressionsInScope.OfType().FirstOrDefault(); - - sort = _resourceDefinitionAccessor.OnApplySort(resourceContext.ResourceType, sort); - - if (sort == null) - { - AttrAttribute idAttribute = GetIdAttribute(resourceContext); - sort = new SortExpression(new SortElementExpression(new ResourceFieldChainExpression(idAttribute), true).AsArray()); - } - - return sort; - } - - protected virtual PaginationExpression GetPagination(IReadOnlyCollection expressionsInScope, ResourceContext resourceContext) - { - ArgumentGuard.NotNull(expressionsInScope, nameof(expressionsInScope)); - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); - - PaginationExpression pagination = expressionsInScope.OfType().FirstOrDefault(); - - pagination = _resourceDefinitionAccessor.OnApplyPagination(resourceContext.ResourceType, pagination); - - pagination ??= new PaginationExpression(PageNumber.ValueOne, _options.DefaultPageSize); - - return pagination; - } - - protected virtual IDictionary GetProjectionForSparseAttributeSet(ResourceContext resourceContext) - { - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); - - IReadOnlyCollection fieldSet = _sparseFieldSetCache.GetSparseFieldSetForQuery(resourceContext); - - if (!fieldSet.Any()) - { - return null; - } - - HashSet attributeSet = fieldSet.OfType().ToHashSet(); - AttrAttribute idAttribute = GetIdAttribute(resourceContext); - attributeSet.Add(idAttribute); - - return attributeSet.ToDictionary(key => (ResourceFieldAttribute)key, _ => (QueryLayer)null); - } - - private static AttrAttribute GetIdAttribute(ResourceContext resourceContext) - { - return resourceContext.Attributes.Single(attr => attr.Property.Name == 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 ef3b23ad84..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/IncludeClauseBuilder.cs +++ /dev/null @@ -1,94 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -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 IncludeChainConverter(); - - private readonly Expression _source; - private readonly ResourceContext _resourceContext; - private readonly IResourceContextProvider _resourceContextProvider; - - public IncludeClauseBuilder(Expression source, LambdaScope lambdaScope, ResourceContext resourceContext, - IResourceContextProvider resourceContextProvider) - : base(lambdaScope) - { - ArgumentGuard.NotNull(source, nameof(source)); - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); - ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider)); - - _source = source; - _resourceContext = resourceContext; - _resourceContextProvider = resourceContextProvider; - } - - public Expression ApplyInclude(IncludeExpression include) - { - ArgumentGuard.NotNull(include, nameof(include)); - - return Visit(include, null); - } - - public override Expression VisitInclude(IncludeExpression expression, object argument) - { - Expression source = ApplyEagerLoads(_source, _resourceContext.EagerLoads, null); - - foreach (ResourceFieldChainExpression chain in IncludeChainConverter.GetRelationshipChains(expression)) - { - source = ProcessRelationshipChain(chain, source); - } - - return source; - } - - private Expression ProcessRelationshipChain(ResourceFieldChainExpression chain, Expression source) - { - string path = null; - Expression result = source; - - foreach (RelationshipAttribute relationship in chain.Fields.Cast()) - { - path = path == null ? relationship.RelationshipPath : path + "." + relationship.RelationshipPath; - - ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(relationship.RightType); - result = ApplyEagerLoads(result, resourceContext.EagerLoads, path); - } - - return IncludeExtensionMethodCall(result, path); - } - - private Expression ApplyEagerLoads(Expression source, IEnumerable eagerLoads, string pathPrefix) - { - Expression result = source; - - foreach (EagerLoadAttribute eagerLoad in eagerLoads) - { - string path = pathPrefix != null ? pathPrefix + "." + eagerLoad.Property.Name : eagerLoad.Property.Name; - result = IncludeExtensionMethodCall(result, path); - - result = ApplyEagerLoads(result, eagerLoad.Children, path); - } - - return result; - } - - 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 6309c8ac54..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaParameterNameFactory.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.Collections.Generic; -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 HashSet(); - - 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 6d555ed23e..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaParameterNameScope.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; -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 208c1a9fb3..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScope.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; -using System.Linq.Expressions; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources.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; } - public HasManyThroughAttribute HasManyThrough { get; } - - public LambdaScope(LambdaParameterNameFactory nameFactory, Type elementType, Expression accessorExpression, HasManyThroughAttribute hasManyThrough) - { - ArgumentGuard.NotNull(nameFactory, nameof(nameFactory)); - ArgumentGuard.NotNull(elementType, nameof(elementType)); - - _parameterNameScope = nameFactory.Create(elementType.Name); - Parameter = Expression.Parameter(elementType, _parameterNameScope.Name); - - if (accessorExpression != null) - { - Accessor = accessorExpression; - } - else if (hasManyThrough != null) - { - Accessor = Expression.Property(Parameter, hasManyThrough.RightProperty); - } - else - { - Accessor = Parameter; - } - - HasManyThrough = hasManyThrough; - } - - 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 d9dc5b6a19..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/LambdaScopeFactory.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using System.Linq.Expressions; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding -{ - [PublicAPI] - public sealed class LambdaScopeFactory - { - private readonly LambdaParameterNameFactory _nameFactory; - private readonly HasManyThroughAttribute _hasManyThrough; - - public LambdaScopeFactory(LambdaParameterNameFactory nameFactory, HasManyThroughAttribute hasManyThrough = null) - { - ArgumentGuard.NotNull(nameFactory, nameof(nameFactory)); - - _nameFactory = nameFactory; - _hasManyThrough = hasManyThrough; - } - - public LambdaScope CreateScope(Type elementType, Expression accessorExpression = null) - { - ArgumentGuard.NotNull(elementType, nameof(elementType)); - - return new LambdaScope(_nameFactory, elementType, accessorExpression, _hasManyThrough); - } - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/OrderClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/OrderClauseBuilder.cs deleted file mode 100644 index 543a89d673..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/OrderClauseBuilder.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using JetBrains.Annotations; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources.Annotations; - -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); - } - - protected override MemberExpression CreatePropertyExpressionForFieldChain(IReadOnlyCollection chain, Expression source) - { - string[] components = chain.Select(GetPropertyName).ToArray(); - return CreatePropertyExpressionFromComponents(LambdaScope.Accessor, components); - } - - private static string GetPropertyName(ResourceFieldAttribute field) - { - // In case of a HasManyThrough access (from count() function), we only need to look at the number of entries in the join table. - return field is HasManyThroughAttribute hasManyThrough ? hasManyThrough.ThroughProperty.Name : field.Property.Name; - } - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs deleted file mode 100644 index e8a8b8a6da..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryClauseBuilder.cs +++ /dev/null @@ -1,120 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -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; } - - 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 = TryGetCollectionCount(collectionExpression); - - if (propertyExpression == null) - { - throw new InvalidOperationException($"Field '{expression.TargetCollection}' must be a collection."); - } - - return propertyExpression; - } - - private static Expression TryGetCollectionCount(Expression collectionExpression) - { - 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 == "Count" || property.Name == "Length") - { - return Expression.Property(collectionExpression, property); - } - } - - return null; - } - - public override Expression VisitResourceFieldChain(ResourceFieldChainExpression expression, TArgument argument) - { - return CreatePropertyExpressionForFieldChain(expression.Fields, LambdaScope.Accessor); - } - - protected virtual MemberExpression CreatePropertyExpressionForFieldChain(IReadOnlyCollection chain, Expression source) - { - string[] components = chain.Select(field => field is RelationshipAttribute relationship ? relationship.RelationshipPath : field.Property.Name) - .ToArray(); - - return CreatePropertyExpressionFromComponents(source, components); - } - - protected static MemberExpression CreatePropertyExpressionFromComponents(Expression source, IReadOnlyCollection components) - { - MemberExpression property = null; - - foreach (string propertyName in components) - { - Type parentType = property == null ? source.Type : property.Type; - - if (parentType.GetProperty(propertyName) == null) - { - throw new InvalidOperationException($"Type '{parentType.Name}' does not contain a property named '{propertyName}'."); - } - - property = property == null ? Expression.Property(source, propertyName) : Expression.Property(property, propertyName); - } - - return property; - } - - protected Expression CreateTupleAccessExpressionForConstant(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 tupleCreateMethod = typeof(Tuple).GetMethods() - .Single(method => method.Name == "Create" && method.IsGenericMethod && method.GetGenericArguments().Length == 1); - - MethodInfo constructedTupleCreateMethod = tupleCreateMethod.MakeGenericMethod(type); - - ConstantExpression constantExpression = Expression.Constant(value, type); - - MethodCallExpression tupleCreateCall = Expression.Call(constructedTupleCreateMethod, constantExpression); - return Expression.Property(tupleCreateCall, "Item1"); - } - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs deleted file mode 100644 index c49993e25c..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/QueryableBuilder.cs +++ /dev/null @@ -1,125 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq.Expressions; -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 -{ - /// - /// 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 IResourceContextProvider _resourceContextProvider; - private readonly IModel _entityModel; - private readonly LambdaScopeFactory _lambdaScopeFactory; - - public QueryableBuilder(Expression source, Type elementType, Type extensionType, LambdaParameterNameFactory nameFactory, - IResourceFactory resourceFactory, IResourceContextProvider resourceContextProvider, 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(resourceContextProvider, nameof(resourceContextProvider)); - ArgumentGuard.NotNull(entityModel, nameof(entityModel)); - - _source = source; - _elementType = elementType; - _extensionType = extensionType; - _nameFactory = nameFactory; - _resourceFactory = resourceFactory; - _resourceContextProvider = resourceContextProvider; - _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.ResourceContext); - } - - 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.Projection.IsNullOrEmpty()) - { - expression = ApplyProjection(expression, layer.Projection, layer.ResourceContext); - } - - return expression; - } - - protected virtual Expression ApplyInclude(Expression source, IncludeExpression include, ResourceContext resourceContext) - { - using LambdaScope lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); - - var builder = new IncludeClauseBuilder(source, lambdaScope, resourceContext, _resourceContextProvider); - 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 ApplyProjection(Expression source, IDictionary projection, - ResourceContext resourceContext) - { - using LambdaScope lambdaScope = _lambdaScopeFactory.CreateScope(_elementType); - - var builder = new SelectClauseBuilder(source, lambdaScope, _entityModel, _extensionType, _nameFactory, _resourceFactory, _resourceContextProvider); - return builder.ApplySelect(projection, resourceContext); - } - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs deleted file mode 100644 index 42bf327fdb..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SelectClauseBuilder.cs +++ /dev/null @@ -1,267 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using System.Reflection; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Repositories; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata; - -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding -{ - /// - /// Transforms into - /// calls. - /// - [PublicAPI] - public class SelectClauseBuilder : QueryClauseBuilder - { - private static readonly CollectionConverter CollectionConverter = new CollectionConverter(); - 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; - private readonly IResourceContextProvider _resourceContextProvider; - - public SelectClauseBuilder(Expression source, LambdaScope lambdaScope, IModel entityModel, Type extensionType, LambdaParameterNameFactory nameFactory, - IResourceFactory resourceFactory, IResourceContextProvider resourceContextProvider) - : 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)); - ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider)); - - _source = source; - _entityModel = entityModel; - _extensionType = extensionType; - _nameFactory = nameFactory; - _resourceFactory = resourceFactory; - _resourceContextProvider = resourceContextProvider; - } - - public Expression ApplySelect(IDictionary selectors, ResourceContext resourceContext) - { - ArgumentGuard.NotNull(selectors, nameof(selectors)); - - if (!selectors.Any()) - { - return _source; - } - - Expression bodyInitializer = CreateLambdaBodyInitializer(selectors, resourceContext, LambdaScope, false); - - LambdaExpression lambda = Expression.Lambda(bodyInitializer, LambdaScope.Parameter); - - return SelectExtensionMethodCall(_source, LambdaScope.Parameter.Type, lambda); - } - - private Expression CreateLambdaBodyInitializer(IDictionary selectors, ResourceContext resourceContext, - LambdaScope lambdaScope, bool lambdaAccessorRequiresTestForNull) - { - ICollection propertySelectors = ToPropertySelectors(selectors, resourceContext, lambdaScope.Accessor.Type); - - MemberBinding[] propertyAssignments = - propertySelectors.Select(selector => CreatePropertyAssignment(selector, lambdaScope)).Cast().ToArray(); - - NewExpression newExpression = _resourceFactory.CreateNewExpression(lambdaScope.Accessor.Type); - Expression memberInit = Expression.MemberInit(newExpression, propertyAssignments); - - if (lambdaScope.HasManyThrough != null) - { - MemberBinding outerPropertyAssignment = Expression.Bind(lambdaScope.HasManyThrough.RightProperty, memberInit); - - NewExpression outerNewExpression = _resourceFactory.CreateNewExpression(lambdaScope.HasManyThrough.ThroughType); - memberInit = Expression.MemberInit(outerNewExpression, outerPropertyAssignment); - } - - if (!lambdaAccessorRequiresTestForNull) - { - return memberInit; - } - - return TestForNull(lambdaScope.Accessor, memberInit); - } - - private ICollection ToPropertySelectors(IDictionary resourceFieldSelectors, - ResourceContext resourceContext, Type elementType) - { - var propertySelectors = new Dictionary(); - - // If a read-only attribute is selected, its value likely depends on another property, so select all resource properties. - bool includesReadOnlyAttribute = resourceFieldSelectors.Any(selector => - selector.Key is AttrAttribute attribute && attribute.Property.SetMethod == null); - - bool containsOnlyRelationships = resourceFieldSelectors.All(selector => selector.Key is RelationshipAttribute); - - foreach (KeyValuePair fieldSelector in resourceFieldSelectors) - { - var propertySelector = new PropertySelector(fieldSelector.Key, fieldSelector.Value); - - if (propertySelector.Property.SetMethod != null) - { - propertySelectors[propertySelector.Property] = propertySelector; - } - } - - if (includesReadOnlyAttribute || containsOnlyRelationships) - { - 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); - - if (propertySelector.Property.SetMethod != null) - { - propertySelectors[propertySelector.Property] = propertySelector; - } - } - } - - foreach (EagerLoadAttribute eagerLoad in resourceContext.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; - } - - propertySelectors[propertySelector.Property] = propertySelector; - } - - return propertySelectors.Values; - } - - private MemberAssignment CreatePropertyAssignment(PropertySelector selector, LambdaScope lambdaScope) - { - MemberExpression propertyAccess = Expression.Property(lambdaScope.Accessor, selector.Property); - - Expression assignmentRightHandSide = propertyAccess; - - if (selector.NextLayer != null) - { - var hasManyThrough = selector.OriginatingField as HasManyThroughAttribute; - var lambdaScopeFactory = new LambdaScopeFactory(_nameFactory, hasManyThrough); - - assignmentRightHandSide = CreateAssignmentRightHandSideForLayer(selector.NextLayer, lambdaScope, propertyAccess, - selector.Property, lambdaScopeFactory); - } - - return Expression.Bind(selector.Property, assignmentRightHandSide); - } - - private Expression CreateAssignmentRightHandSideForLayer(QueryLayer layer, LambdaScope outerLambdaScope, MemberExpression propertyAccess, - PropertyInfo selectorPropertyInfo, LambdaScopeFactory lambdaScopeFactory) - { - Type collectionElementType = CollectionConverter.TryGetCollectionElementType(selectorPropertyInfo.PropertyType); - Type bodyElementType = collectionElementType ?? selectorPropertyInfo.PropertyType; - - if (collectionElementType != null) - { - return CreateCollectionInitializer(outerLambdaScope, selectorPropertyInfo, bodyElementType, layer, lambdaScopeFactory); - } - - if (layer.Projection.IsNullOrEmpty()) - { - return propertyAccess; - } - - using LambdaScope scope = lambdaScopeFactory.CreateScope(bodyElementType, propertyAccess); - return CreateLambdaBodyInitializer(layer.Projection, layer.ResourceContext, 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, _resourceContextProvider, - _entityModel, lambdaScopeFactory); - - Expression layerExpression = builder.ApplyQuery(layer); - - // Earlier versions of EF Core 3.x failed to understand `query.ToHashSet()`, so we emit `new HashSet(query)` instead. - // Interestingly, EF Core 5 RC1 fails to understand `new HashSet(query)`, so we emit `query.ToHashSet()` instead. - // https://github.com/dotnet/efcore/issues/22902 - - if (EntityFrameworkCoreSupport.Version.Major < 5) - { - Type enumerableOfElementType = typeof(IEnumerable<>).MakeGenericType(elementType); - Type typedCollection = CollectionConverter.ToConcreteCollectionType(collectionProperty.PropertyType); - - ConstructorInfo typedCollectionConstructor = typedCollection.GetConstructor(enumerableOfElementType.AsArray()); - - if (typedCollectionConstructor == null) - { - throw new InvalidOperationException($"Constructor on '{typedCollection.Name}' that accepts '{enumerableOfElementType.Name}' not found."); - } - - return Expression.New(typedCollectionConstructor, layerExpression); - } - - 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 selectorBody) - { - Type[] typeArguments = ArrayFactory.Create(elementType, elementType); - return Expression.Call(_extensionType, "Select", typeArguments, source, selectorBody); - } - - private sealed class PropertySelector - { - public PropertyInfo Property { get; } - public ResourceFieldAttribute OriginatingField { get; } - public QueryLayer NextLayer { get; } - - public PropertySelector(PropertyInfo property, QueryLayer nextLayer = null) - { - ArgumentGuard.NotNull(property, nameof(property)); - - Property = property; - NextLayer = nextLayer; - } - - public PropertySelector(ResourceFieldAttribute field, QueryLayer nextLayer = null) - { - ArgumentGuard.NotNull(field, nameof(field)); - - OriginatingField = field; - NextLayer = nextLayer; - Property = field is HasManyThroughAttribute hasManyThrough ? hasManyThrough.ThroughProperty : field.Property; - } - - 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 3b66d20903..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/SkipTakeClauseBuilder.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System; -using System.Linq; -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 = CreateTupleAccessExpressionForConstant(value, 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 191e911cf9..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/QueryableBuilding/WhereClauseBuilder.cs +++ /dev/null @@ -1,307 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Linq.Expressions; -using JetBrains.Annotations; -using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Resources.Internal; - -namespace JsonApiDotNetCore.Queries.Internal.QueryableBuilding -{ - /// - /// Transforms into - /// calls. - /// - [PublicAPI] - public class WhereClauseBuilder : QueryClauseBuilder - { - private static readonly CollectionConverter CollectionConverter = new CollectionConverter(); - 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 VisitCollectionNotEmpty(CollectionNotEmptyExpression expression, Type argument) - { - Expression property = Visit(expression.TargetCollection, argument); - - Type elementType = CollectionConverter.TryGetCollectionElementType(property.Type); - - if (elementType == null) - { - throw new InvalidOperationException("Expression must be a collection."); - } - - Expression predicate = null; - - if (expression.Filter != null) - { - var hasManyThrough = expression.TargetCollection.Fields.Last() as HasManyThroughAttribute; - var lambdaScopeFactory = new LambdaScopeFactory(_nameFactory, hasManyThrough); - 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) - { - if (predicate != null) - { - return Expression.Call(typeof(Enumerable), "Any", elementType.AsArray(), source, predicate); - } - - return Expression.Call(typeof(Enumerable), "Any", elementType.AsArray(), source); - } - - 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 VisitEqualsAnyOf(EqualsAnyOfExpression 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 = TryResolveCommonType(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 TryResolveCommonType(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 CreateTupleAccessExpressionForConstant(convertedValue, 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); - } - } - - protected override MemberExpression CreatePropertyExpressionForFieldChain(IReadOnlyCollection chain, Expression source) - { - string[] components = chain.Select(GetPropertyName).ToArray(); - return CreatePropertyExpressionFromComponents(LambdaScope.Accessor, components); - } - - private static string GetPropertyName(ResourceFieldAttribute field) - { - return field is HasManyThroughAttribute hasManyThrough ? hasManyThrough.ThroughProperty.Name : field.Property.Name; - } - } -} diff --git a/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs b/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs deleted file mode 100644 index c44a0a34db..0000000000 --- a/src/JsonApiDotNetCore/Queries/Internal/SparseFieldSetCache.cs +++ /dev/null @@ -1,170 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Queries.Internal -{ - /// - /// Takes sparse fieldsets from s and invokes - /// on them. - /// - [PublicAPI] - public sealed class SparseFieldSetCache - { - 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>(); - - foreach ((ResourceContext resourceContext, SparseFieldSetExpression sparseFieldSet) in sparseFieldTables) - { - if (!mergedTable.ContainsKey(resourceContext)) - { - mergedTable[resourceContext] = new HashSet(); - } - - AddSparseFieldsToSet(sparseFieldSet.Fields, mergedTable[resourceContext]); - } - - return mergedTable; - } - - private static void AddSparseFieldsToSet(IReadOnlyCollection sparseFieldsToAdd, HashSet sparseFieldSet) - { - foreach (ResourceFieldAttribute field in sparseFieldsToAdd) - { - sparseFieldSet.Add(field); - } - } - - public IReadOnlyCollection GetSparseFieldSetForQuery(ResourceContext resourceContext) - { - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); - - if (!_visitedTable.ContainsKey(resourceContext)) - { - SparseFieldSetExpression inputExpression = _lazySourceTable.Value.ContainsKey(resourceContext) - ? new SparseFieldSetExpression(_lazySourceTable.Value[resourceContext]) - : null; - - SparseFieldSetExpression outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceContext.ResourceType, inputExpression); - - HashSet outputFields = outputExpression == null - ? new HashSet() - : outputExpression.Fields.ToHashSet(); - - _visitedTable[resourceContext] = outputFields; - } - - return _visitedTable[resourceContext]; - } - - public IReadOnlyCollection GetIdAttributeSetForRelationshipQuery(ResourceContext resourceContext) - { - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); - - AttrAttribute idAttribute = resourceContext.Attributes.Single(attr => attr.Property.Name == nameof(Identifiable.Id)); - var inputExpression = new SparseFieldSetExpression(idAttribute.AsArray()); - - // Intentionally not cached, as we are fetching ID only (ignoring any sparse fieldset that came from query string). - SparseFieldSetExpression outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceContext.ResourceType, inputExpression); - - HashSet outputAttributes = outputExpression == null - ? new HashSet() - : outputExpression.Fields.OfType().ToHashSet(); - - outputAttributes.Add(idAttribute); - return outputAttributes; - } - - public IReadOnlyCollection GetSparseFieldSetForSerializer(ResourceContext resourceContext) - { - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); - - if (!_visitedTable.ContainsKey(resourceContext)) - { - HashSet inputFields = _lazySourceTable.Value.ContainsKey(resourceContext) - ? _lazySourceTable.Value[resourceContext] - : GetResourceFields(resourceContext); - - var inputExpression = new SparseFieldSetExpression(inputFields); - SparseFieldSetExpression outputExpression = _resourceDefinitionAccessor.OnApplySparseFieldSet(resourceContext.ResourceType, inputExpression); - - HashSet outputFields; - - if (outputExpression == null) - { - outputFields = GetResourceFields(resourceContext); - } - else - { - outputFields = new HashSet(inputFields); - outputFields.IntersectWith(outputExpression.Fields); - } - - _visitedTable[resourceContext] = outputFields; - } - - return _visitedTable[resourceContext]; - } - -#pragma warning disable AV1130 // Return type in method signature should be a collection interface instead of a concrete type - private HashSet GetResourceFields(ResourceContext resourceContext) -#pragma warning restore AV1130 // Return type in method signature should be a collection interface instead of a concrete type - { - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); - - var fieldSet = new HashSet(); - - foreach (AttrAttribute attribute in resourceContext.Attributes.Where(attr => attr.Capabilities.HasFlag(AttrCapabilities.AllowView))) - { - fieldSet.Add(attribute); - } - - foreach (RelationshipAttribute relationship in resourceContext.Relationships) - { - fieldSet.Add(relationship); - } - - return fieldSet; - } - - public void Reset() - { - _visitedTable.Clear(); - } - } -} diff --git a/src/JsonApiDotNetCore/Queries/PaginationContext.cs b/src/JsonApiDotNetCore/Queries/PaginationContext.cs index beb760555c..c433f383da 100644 --- a/src/JsonApiDotNetCore/Queries/PaginationContext.cs +++ b/src/JsonApiDotNetCore/Queries/PaginationContext.cs @@ -1,25 +1,23 @@ -using System; using JsonApiDotNetCore.Configuration; -namespace JsonApiDotNetCore.Queries +namespace JsonApiDotNetCore.Queries; + +/// +internal sealed class PaginationContext : IPaginationContext { /// - internal sealed class PaginationContext : IPaginationContext - { - /// - public PageNumber PageNumber { get; set; } + public PageNumber PageNumber { get; set; } = PageNumber.ValueOne; - /// - public PageSize PageSize { get; set; } + /// + public PageSize? PageSize { get; set; } - /// - public bool IsPageFull { get; set; } + /// + public bool IsPageFull { get; set; } - /// - public int? TotalResourceCount { get; set; } + /// + public int? TotalResourceCount { get; set; } - /// - public int? TotalPageCount => - TotalResourceCount == null || PageSize == null ? null : (int?)Math.Ceiling((decimal)TotalResourceCount.Value / PageSize.Value); - } + /// + public int? TotalPageCount => + TotalResourceCount == null || PageSize == null ? null : (int?)Math.Ceiling((decimal)TotalResourceCount.Value / PageSize.Value); } 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/Parsing/Keywords.cs b/src/JsonApiDotNetCore/Queries/Parsing/Keywords.cs new file mode 100644 index 0000000000..66538117fd --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/Keywords.cs @@ -0,0 +1,27 @@ +using JetBrains.Annotations; + +#pragma warning disable AV1008 // Class should not be static +#pragma warning disable AV1010 // Member hides inherited member + +namespace JsonApiDotNetCore.Queries.Parsing; + +[PublicAPI] +public static class Keywords +{ + public const string Null = "null"; + public const string Not = "not"; + public const string And = "and"; + public const string Or = "or"; + public new const string Equals = "equals"; + public const string GreaterThan = "greaterThan"; + public const string GreaterOrEqual = "greaterOrEqual"; + public const string LessThan = "lessThan"; + public const string LessOrEqual = "lessOrEqual"; + public const string Contains = "contains"; + public const string StartsWith = "startsWith"; + public const string EndsWith = "endsWith"; + public const string Any = "any"; + public const string Count = "count"; + public const string Has = "has"; + public const string IsType = "isType"; +} 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/Parsing/QueryTokenizer.cs b/src/JsonApiDotNetCore/Queries/Parsing/QueryTokenizer.cs new file mode 100644 index 0000000000..0e1e7660a2 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/QueryTokenizer.cs @@ -0,0 +1,151 @@ +using System.Collections.ObjectModel; +using System.Text; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Queries.Parsing; + +[PublicAPI] +public sealed class QueryTokenizer +{ + public static readonly IReadOnlyDictionary SingleCharacterToTokenKinds = new ReadOnlyDictionary( + new Dictionary + { + ['('] = TokenKind.OpenParen, + [')'] = TokenKind.CloseParen, + ['['] = TokenKind.OpenBracket, + [']'] = TokenKind.CloseBracket, + ['.'] = TokenKind.Period, + [','] = TokenKind.Comma, + [':'] = TokenKind.Colon, + ['-'] = TokenKind.Minus + }); + + private readonly string _source; + private readonly StringBuilder _textBuffer = new(); + private int _sourceOffset; + private int? _tokenStartOffset; + private bool _isInQuotedSection; + + public QueryTokenizer(string source) + { + ArgumentNullException.ThrowIfNull(source); + + _source = source; + } + + public IEnumerable EnumerateTokens() + { + _textBuffer.Clear(); + _isInQuotedSection = false; + _sourceOffset = 0; + _tokenStartOffset = null; + + while (_sourceOffset < _source.Length) + { + _tokenStartOffset ??= _sourceOffset; + + char ch = _source[_sourceOffset]; + + if (ch == '\'') + { + if (_isInQuotedSection) + { + char? peeked = PeekChar(); + + if (peeked == '\'') + { + _textBuffer.Append(ch); + _sourceOffset += 2; + continue; + } + + _isInQuotedSection = false; + + Token literalToken = ProduceTokenFromTextBuffer(true)!; + yield return literalToken; + } + else + { + if (_textBuffer.Length > 0) + { + throw new QueryParseException("Unexpected ' outside text.", _sourceOffset); + } + + _isInQuotedSection = true; + } + } + else + { + TokenKind? singleCharacterTokenKind = _isInQuotedSection ? null : TryGetSingleCharacterTokenKind(ch); + + if (singleCharacterTokenKind != null && !IsMinusInsideText(singleCharacterTokenKind.Value)) + { + Token? identifierToken = ProduceTokenFromTextBuffer(false); + + if (identifierToken != null) + { + yield return identifierToken; + } + + yield return new Token(singleCharacterTokenKind.Value, _sourceOffset); + + _tokenStartOffset = null; + } + else + { + if (ch == ' ' && !_isInQuotedSection) + { + throw new QueryParseException("Unexpected whitespace.", _sourceOffset); + } + + _textBuffer.Append(ch); + } + } + + _sourceOffset++; + } + + if (_isInQuotedSection) + { + throw new QueryParseException("' expected.", _sourceOffset - 1); + } + + Token? lastToken = ProduceTokenFromTextBuffer(false); + + if (lastToken != null) + { + yield return lastToken; + } + } + + private bool IsMinusInsideText(TokenKind kind) + { + return kind == TokenKind.Minus && _textBuffer.Length > 0; + } + + private char? PeekChar() + { + return _sourceOffset + 1 < _source.Length ? _source[_sourceOffset + 1] : null; + } + + private static TokenKind? TryGetSingleCharacterTokenKind(char ch) + { + return SingleCharacterToTokenKinds.TryGetValue(ch, out TokenKind tokenKind) ? tokenKind : null; + } + + private Token? ProduceTokenFromTextBuffer(bool isQuotedText) + { + if (isQuotedText || _textBuffer.Length > 0) + { + int tokenStartOffset = _tokenStartOffset!.Value; + string text = _textBuffer.ToString(); + + _textBuffer.Clear(); + _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/Parsing/TokenKind.cs b/src/JsonApiDotNetCore/Queries/Parsing/TokenKind.cs new file mode 100644 index 0000000000..23fd428bf5 --- /dev/null +++ b/src/JsonApiDotNetCore/Queries/Parsing/TokenKind.cs @@ -0,0 +1,15 @@ +namespace JsonApiDotNetCore.Queries.Parsing; + +public enum TokenKind +{ + OpenParen, + CloseParen, + OpenBracket, + CloseBracket, + Period, + Comma, + Colon, + Minus, + Text, + QuotedText +} diff --git a/src/JsonApiDotNetCore/Queries/QueryLayer.cs b/src/JsonApiDotNetCore/Queries/QueryLayer.cs index 1ea0f62733..49a9ee92a2 100644 --- a/src/JsonApiDotNetCore/Queries/QueryLayer.cs +++ b/src/JsonApiDotNetCore/Queries/QueryLayer.cs @@ -1,126 +1,73 @@ -using System; -using System.Collections.Generic; using System.Text; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Queries -{ - /// - /// A nested data structure that contains constraints per resource type. - /// - [PublicAPI] - public sealed class QueryLayer - { - public ResourceContext ResourceContext { get; } - - public IncludeExpression Include { get; set; } - public FilterExpression Filter { get; set; } - public SortExpression Sort { get; set; } - public PaginationExpression Pagination { get; set; } - public IDictionary Projection { get; set; } - - public QueryLayer(ResourceContext resourceContext) - { - ArgumentGuard.NotNull(resourceContext, nameof(resourceContext)); - - ResourceContext = resourceContext; - } - - public override string ToString() - { - var builder = new StringBuilder(); +namespace JsonApiDotNetCore.Queries; - var writer = new IndentingStringWriter(builder); - WriteLayer(writer, this); +/// +/// A nested data structure that contains constraints per resource type. +/// +[PublicAPI] +public sealed class QueryLayer +{ + internal bool IsEmpty => Filter == null && Sort == null && Pagination?.PageSize == null && (Selection == null || Selection.IsEmpty); - return builder.ToString(); - } + public ResourceType ResourceType { get; } - private static void WriteLayer(IndentingStringWriter writer, QueryLayer layer, string prefix = null) - { - writer.WriteLine(prefix + nameof(QueryLayer) + "<" + layer.ResourceContext.ResourceType.Name + ">"); + public IncludeExpression? Include { get; set; } + public FilterExpression? Filter { get; set; } + public SortExpression? Sort { get; set; } + public PaginationExpression? Pagination { get; set; } + public FieldSelection? Selection { get; set; } - using (writer.Indent()) - { - if (layer.Include != null) - { - writer.WriteLine($"{nameof(Include)}: {layer.Include}"); - } + public QueryLayer(ResourceType resourceType) + { + ArgumentNullException.ThrowIfNull(resourceType); - if (layer.Filter != null) - { - writer.WriteLine($"{nameof(Filter)}: {layer.Filter}"); - } + ResourceType = resourceType; + } - if (layer.Sort != null) - { - writer.WriteLine($"{nameof(Sort)}: {layer.Sort}"); - } + public override string ToString() + { + var builder = new StringBuilder(); - if (layer.Pagination != null) - { - writer.WriteLine($"{nameof(Pagination)}: {layer.Pagination}"); - } + var writer = new IndentingStringWriter(builder); + WriteLayer(writer, null); - if (!layer.Projection.IsNullOrEmpty()) - { - writer.WriteLine(nameof(Projection)); + return builder.ToString(); + } - using (writer.Indent()) - { - foreach ((ResourceFieldAttribute field, QueryLayer nextLayer) in layer.Projection) - { - if (nextLayer == null) - { - writer.WriteLine(field.ToString()); - } - else - { - WriteLayer(writer, nextLayer, field.PublicName + ": "); - } - } - } - } - } - } + internal void WriteLayer(IndentingStringWriter writer, string? prefix) + { + writer.WriteLine($"{prefix}{nameof(QueryLayer)}<{ResourceType.ClrType.Name}>"); - private sealed class IndentingStringWriter : IDisposable + using (writer.Indent()) { - private readonly StringBuilder _builder; - private int _indentDepth; - - public IndentingStringWriter(StringBuilder builder) + if (Include is { Elements.Count: > 0 }) { - _builder = builder; + writer.WriteLine($"{nameof(Include)}: {Include}"); } - public void WriteLine(string line) + if (Filter != null) { - if (_indentDepth > 0) - { - _builder.Append(new string(' ', _indentDepth * 2)); - } + writer.WriteLine($"{nameof(Filter)}: {Filter}"); + } - _builder.AppendLine(line); + if (Sort != null) + { + writer.WriteLine($"{nameof(Sort)}: {Sort}"); } - public IndentingStringWriter Indent() + if (Pagination != null) { - WriteLine("{"); - _indentDepth++; - return this; + writer.WriteLine($"{nameof(Pagination)}: {Pagination}"); } - public void Dispose() + if (Selection is { IsEmpty: false }) { - if (_indentDepth > 0) - { - _indentDepth--; - WriteLine("}"); - } + writer.WriteLine(nameof(Selection)); + Selection.WriteSelection(writer); } } } 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/Queries/TopFieldSelection.cs b/src/JsonApiDotNetCore/Queries/TopFieldSelection.cs index d5203ff537..d2ebabde52 100644 --- a/src/JsonApiDotNetCore/Queries/TopFieldSelection.cs +++ b/src/JsonApiDotNetCore/Queries/TopFieldSelection.cs @@ -1,23 +1,22 @@ -namespace JsonApiDotNetCore.Queries +namespace JsonApiDotNetCore.Queries; + +/// +/// Indicates how to override sparse fieldset selection coming from constraints. +/// +public enum TopFieldSelection { /// - /// Indicates how to override sparse fieldset selection coming from constraints. + /// Preserves the existing selection of attributes and/or relationships. /// - public enum TopFieldSelection - { - /// - /// Preserves the existing selection of attributes and/or relationships. - /// - PreserveExisting, + PreserveExisting, - /// - /// Preserves included relationships, but selects all resource attributes. - /// - WithAllAttributes, + /// + /// Preserves included relationships, but selects all resource attributes. + /// + WithAllAttributes, - /// - /// Discards any included relationships and selects only resource ID. - /// - OnlyIdAttribute - } + /// + /// Discards any included relationships and selects only resource ID. + /// + OnlyIdAttribute } 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/IDefaultsQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IDefaultsQueryStringParameterReader.cs deleted file mode 100644 index 176bf69bda..0000000000 --- a/src/JsonApiDotNetCore/QueryStrings/IDefaultsQueryStringParameterReader.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Newtonsoft.Json; - -namespace JsonApiDotNetCore.QueryStrings -{ - /// - /// Reads the 'defaults' query string parameter. - /// - public interface IDefaultsQueryStringParameterReader : IQueryStringParameterReader - { - /// - /// Contains the effective value of default configuration and query string override, after parsing has occurred. - /// - DefaultValueHandling SerializerDefaultValueHandling { get; } - } -} diff --git a/src/JsonApiDotNetCore/QueryStrings/IFilterQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IFilterQueryStringParameterReader.cs index 49caf84786..4fdefe2124 100644 --- a/src/JsonApiDotNetCore/QueryStrings/IFilterQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IFilterQueryStringParameterReader.cs @@ -1,13 +1,10 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Queries; -namespace JsonApiDotNetCore.QueryStrings -{ - /// - /// Reads the 'filter' query string parameter and produces a set of query constraints from it. - /// - [PublicAPI] - public interface IFilterQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider - { - } -} +namespace JsonApiDotNetCore.QueryStrings; + +/// +/// Reads the 'filter' query string parameter and produces a set of query constraints from it. +/// +[PublicAPI] +public interface IFilterQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider; diff --git a/src/JsonApiDotNetCore/QueryStrings/IIncludeQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IIncludeQueryStringParameterReader.cs index e348f3635c..822df2ee68 100644 --- a/src/JsonApiDotNetCore/QueryStrings/IIncludeQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IIncludeQueryStringParameterReader.cs @@ -1,13 +1,10 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Queries; -namespace JsonApiDotNetCore.QueryStrings -{ - /// - /// Reads the 'include' query string parameter and produces a set of query constraints from it. - /// - [PublicAPI] - public interface IIncludeQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider - { - } -} +namespace JsonApiDotNetCore.QueryStrings; + +/// +/// Reads the 'include' query string parameter and produces a set of query constraints from it. +/// +[PublicAPI] +public interface IIncludeQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider; diff --git a/src/JsonApiDotNetCore/QueryStrings/INullsQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/INullsQueryStringParameterReader.cs deleted file mode 100644 index e1885925e5..0000000000 --- a/src/JsonApiDotNetCore/QueryStrings/INullsQueryStringParameterReader.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Newtonsoft.Json; - -namespace JsonApiDotNetCore.QueryStrings -{ - /// - /// Reads the 'nulls' query string parameter. - /// - public interface INullsQueryStringParameterReader : IQueryStringParameterReader - { - /// - /// Contains the effective value of default configuration and query string override, after parsing has occurred. - /// - NullValueHandling SerializerNullValueHandling { get; } - } -} diff --git a/src/JsonApiDotNetCore/QueryStrings/IPaginationQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IPaginationQueryStringParameterReader.cs index c41f417435..56141e5615 100644 --- a/src/JsonApiDotNetCore/QueryStrings/IPaginationQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IPaginationQueryStringParameterReader.cs @@ -1,13 +1,10 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Queries; -namespace JsonApiDotNetCore.QueryStrings -{ - /// - /// Reads the 'page' query string parameter and produces a set of query constraints from it. - /// - [PublicAPI] - public interface IPaginationQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider - { - } -} +namespace JsonApiDotNetCore.QueryStrings; + +/// +/// Reads the 'page' query string parameter and produces a set of query constraints from it. +/// +[PublicAPI] +public interface IPaginationQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider; diff --git a/src/JsonApiDotNetCore/QueryStrings/IQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IQueryStringParameterReader.cs index 024ee564f2..d4aa74df03 100644 --- a/src/JsonApiDotNetCore/QueryStrings/IQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IQueryStringParameterReader.cs @@ -1,31 +1,30 @@ using JsonApiDotNetCore.Controllers.Annotations; using Microsoft.Extensions.Primitives; -namespace JsonApiDotNetCore.QueryStrings +namespace JsonApiDotNetCore.QueryStrings; + +/// +/// The interface to implement for processing a specific type of query string parameter. +/// +public interface IQueryStringParameterReader { /// - /// The interface to implement for processing a specific type of query string parameter. + /// Indicates whether this reader supports empty query string parameter values. /// - public interface IQueryStringParameterReader - { - /// - /// Indicates whether this reader supports empty query string parameter values. Defaults to false. - /// - bool AllowEmptyValue => false; + bool AllowEmptyValue { get; } - /// - /// Indicates whether usage of this query string parameter is blocked using on a controller. - /// - bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute); + /// + /// Indicates whether usage of this query string parameter is blocked using on a controller. + /// + bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute); - /// - /// Indicates whether this reader can handle the specified query string parameter. - /// - bool CanRead(string parameterName); + /// + /// Indicates whether this reader can handle the specified query string parameter. + /// + bool CanRead(string parameterName); - /// - /// Reads the value of the query string parameter. - /// - void Read(string parameterName, StringValues parameterValue); - } + /// + /// Reads the value of the query string parameter. + /// + void Read(string parameterName, StringValues parameterValue); } diff --git a/src/JsonApiDotNetCore/QueryStrings/IQueryStringReader.cs b/src/JsonApiDotNetCore/QueryStrings/IQueryStringReader.cs index 39c07ec036..935785658b 100644 --- a/src/JsonApiDotNetCore/QueryStrings/IQueryStringReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IQueryStringReader.cs @@ -1,18 +1,17 @@ using JsonApiDotNetCore.Controllers.Annotations; -namespace JsonApiDotNetCore.QueryStrings +namespace JsonApiDotNetCore.QueryStrings; + +/// +/// Reads and processes the various query string parameters for a HTTP request. +/// +public interface IQueryStringReader { /// - /// Reads and processes the various query string parameters for a HTTP request. + /// Reads and processes the key/value pairs from the request query string. /// - public interface IQueryStringReader - { - /// - /// Reads and processes the key/value pairs from the request query string. - /// - /// - /// The if set on the controller that is targeted by the current request. - /// - void ReadAll(DisableQueryStringAttribute disableQueryStringAttribute); - } + /// + /// The if set on the controller that is targeted by the current request. + /// + void ReadAll(DisableQueryStringAttribute? disableQueryStringAttribute); } diff --git a/src/JsonApiDotNetCore/QueryStrings/IRequestQueryStringAccessor.cs b/src/JsonApiDotNetCore/QueryStrings/IRequestQueryStringAccessor.cs index 52a5a5eace..60b7a460a8 100644 --- a/src/JsonApiDotNetCore/QueryStrings/IRequestQueryStringAccessor.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IRequestQueryStringAccessor.cs @@ -1,12 +1,11 @@ using Microsoft.AspNetCore.Http; -namespace JsonApiDotNetCore.QueryStrings +namespace JsonApiDotNetCore.QueryStrings; + +/// +/// Provides access to the query string of a URL in a HTTP request. +/// +public interface IRequestQueryStringAccessor { - /// - /// Provides access to the query string of a URL in a HTTP request. - /// - public interface IRequestQueryStringAccessor - { - IQueryCollection Query { get; } - } + IQueryCollection Query { get; } } diff --git a/src/JsonApiDotNetCore/QueryStrings/IResourceDefinitionQueryableParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/IResourceDefinitionQueryableParameterReader.cs index baea3e2938..965eb2d884 100644 --- a/src/JsonApiDotNetCore/QueryStrings/IResourceDefinitionQueryableParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/IResourceDefinitionQueryableParameterReader.cs @@ -2,14 +2,11 @@ using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.QueryStrings -{ - /// - /// Reads custom query string parameters for which handlers on are registered and produces a set of - /// query constraints from it. - /// - [PublicAPI] - public interface IResourceDefinitionQueryableParameterReader : IQueryStringParameterReader, IQueryConstraintProvider - { - } -} +namespace JsonApiDotNetCore.QueryStrings; + +/// +/// Reads custom query string parameters for which handlers on are registered and produces a set of +/// query constraints from it. +/// +[PublicAPI] +public interface IResourceDefinitionQueryableParameterReader : IQueryStringParameterReader, IQueryConstraintProvider; diff --git a/src/JsonApiDotNetCore/QueryStrings/ISortQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/ISortQueryStringParameterReader.cs index 1fe5f4cb51..763d1a67f1 100644 --- a/src/JsonApiDotNetCore/QueryStrings/ISortQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/ISortQueryStringParameterReader.cs @@ -1,13 +1,10 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Queries; -namespace JsonApiDotNetCore.QueryStrings -{ - /// - /// Reads the 'sort' query string parameter and produces a set of query constraints from it. - /// - [PublicAPI] - public interface ISortQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider - { - } -} +namespace JsonApiDotNetCore.QueryStrings; + +/// +/// Reads the 'sort' query string parameter and produces a set of query constraints from it. +/// +[PublicAPI] +public interface ISortQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider; diff --git a/src/JsonApiDotNetCore/QueryStrings/ISparseFieldSetQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/ISparseFieldSetQueryStringParameterReader.cs index 7a307f1f40..1f0bdaf90f 100644 --- a/src/JsonApiDotNetCore/QueryStrings/ISparseFieldSetQueryStringParameterReader.cs +++ b/src/JsonApiDotNetCore/QueryStrings/ISparseFieldSetQueryStringParameterReader.cs @@ -1,13 +1,10 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Queries; -namespace JsonApiDotNetCore.QueryStrings -{ - /// - /// Reads the 'fields' query string parameter and produces a set of query constraints from it. - /// - [PublicAPI] - public interface ISparseFieldSetQueryStringParameterReader : IQueryStringParameterReader, IQueryConstraintProvider - { - } -} +namespace JsonApiDotNetCore.QueryStrings; + +/// +/// Reads the 'fields' query string parameter and produces a set of query constraints from it. +/// +[PublicAPI] +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/DefaultsQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/DefaultsQueryStringParameterReader.cs deleted file mode 100644 index 0faa4d6ff4..0000000000 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/DefaultsQueryStringParameterReader.cs +++ /dev/null @@ -1,54 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers.Annotations; -using JsonApiDotNetCore.Errors; -using Microsoft.Extensions.Primitives; -using Newtonsoft.Json; - -namespace JsonApiDotNetCore.QueryStrings.Internal -{ - /// - [PublicAPI] - public class DefaultsQueryStringParameterReader : IDefaultsQueryStringParameterReader - { - private readonly IJsonApiOptions _options; - - /// - public DefaultValueHandling SerializerDefaultValueHandling { get; private set; } - - public DefaultsQueryStringParameterReader(IJsonApiOptions options) - { - ArgumentGuard.NotNull(options, nameof(options)); - - _options = options; - SerializerDefaultValueHandling = options.SerializerSettings.DefaultValueHandling; - } - - /// - public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) - { - ArgumentGuard.NotNull(disableQueryStringAttribute, nameof(disableQueryStringAttribute)); - - return _options.AllowQueryStringOverrideForSerializerDefaultValueHandling && - !disableQueryStringAttribute.ContainsParameter(StandardQueryStringParameters.Defaults); - } - - /// - public virtual bool CanRead(string parameterName) - { - return parameterName == "defaults"; - } - - /// - public virtual void Read(string parameterName, StringValues parameterValue) - { - if (!bool.TryParse(parameterValue, out bool result)) - { - throw new InvalidQueryStringParameterException(parameterName, "The specified defaults is invalid.", - $"The value '{parameterValue}' must be 'true' or 'false'."); - } - - SerializerDefaultValueHandling = result ? DefaultValueHandling.Include : DefaultValueHandling.Ignore; - } - } -} diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs deleted file mode 100644 index bcfce7fcd5..0000000000 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/FilterQueryStringParameterReader.cs +++ /dev/null @@ -1,181 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -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 LegacyFilterNotationConverter(); - - private readonly IJsonApiOptions _options; - private readonly QueryStringParameterScopeParser _scopeParser; - private readonly FilterParser _filterParser; - - private readonly List _filtersInGlobalScope = new List(); - - private readonly Dictionary> _filtersPerScope = - new Dictionary>(); - - private string _lastParameterName; - - public FilterQueryStringParameterReader(IJsonApiRequest request, IResourceContextProvider resourceContextProvider, IResourceFactory resourceFactory, - IJsonApiOptions options) - : base(request, resourceContextProvider) - { - ArgumentGuard.NotNull(options, nameof(options)); - - _options = options; - _scopeParser = new QueryStringParameterScopeParser(resourceContextProvider, FieldChainRequirements.EndsInToMany); - _filterParser = new FilterParser(resourceContextProvider, resourceFactory, ValidateSingleField); - } - - protected void ValidateSingleField(ResourceFieldAttribute field, ResourceContext resourceContext, 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(StandardQueryStringParameters.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, RequestResource); - - if (parameterScope.Scope == null) - { - AssertIsCollectionRequest(); - } - - return parameterScope.Scope; - } - - private FilterExpression GetFilter(string parameterValue, ResourceFieldChainExpression scope) - { - ResourceContext resourceContextInScope = GetResourceContextForScope(scope); - return _filterParser.Parse(parameterValue, resourceContextInScope); - } - - private void StoreFilterInScope(FilterExpression filter, ResourceFieldChainExpression scope) - { - if (scope == null) - { - _filtersInGlobalScope.Add(filter); - } - else - { - if (!_filtersPerScope.ContainsKey(scope)) - { - _filtersPerScope[scope] = new List(); - } - - _filtersPerScope[scope].Add(filter); - } - } - - /// - public virtual IReadOnlyCollection GetConstraints() - { - return EnumerateFiltersInScopes().ToArray(); - } - - private IEnumerable EnumerateFiltersInScopes() - { - if (_filtersInGlobalScope.Any()) - { - FilterExpression filter = MergeFilters(_filtersInGlobalScope); - yield return new ExpressionInScope(null, filter); - } - - foreach ((ResourceFieldChainExpression scope, List filters) in _filtersPerScope) - { - FilterExpression filter = MergeFilters(filters); - yield return new ExpressionInScope(scope, filter); - } - } - - private static FilterExpression MergeFilters(IReadOnlyCollection 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 c0fbb4a60d..0000000000 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/IncludeQueryStringParameterReader.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System.Collections.Generic; -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 IncludeQueryStringParameterReader : QueryStringParameterReader, IIncludeQueryStringParameterReader - { - private readonly IJsonApiOptions _options; - private readonly IncludeParser _includeParser; - - private IncludeExpression _includeExpression; - private string _lastParameterName; - - public IncludeQueryStringParameterReader(IJsonApiRequest request, IResourceContextProvider resourceContextProvider, IJsonApiOptions options) - : base(request, resourceContextProvider) - { - ArgumentGuard.NotNull(options, nameof(options)); - - _options = options; - _includeParser = new IncludeParser(resourceContextProvider, ValidateSingleRelationship); - } - - protected void ValidateSingleRelationship(RelationshipAttribute relationship, ResourceContext resourceContext, string path) - { - if (!relationship.CanInclude) - { - throw new InvalidQueryStringParameterException(_lastParameterName, "Including the requested relationship is not allowed.", - path == relationship.PublicName - ? $"Including the relationship '{relationship.PublicName}' on '{resourceContext.PublicName}' is not allowed." - : $"Including the relationship '{relationship.PublicName}' in '{path}' on '{resourceContext.PublicName}' is not allowed."); - } - } - - /// - public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) - { - ArgumentGuard.NotNull(disableQueryStringAttribute, nameof(disableQueryStringAttribute)); - - return !IsAtomicOperationsRequest && !disableQueryStringAttribute.ContainsParameter(StandardQueryStringParameters.Include); - } - - /// - public virtual bool CanRead(string parameterName) - { - return parameterName == "include"; - } - - /// - public virtual void Read(string parameterName, StringValues parameterValue) - { - _lastParameterName = parameterName; - - 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, RequestResource, _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 6be267289d..0000000000 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/LegacyFilterNotationConverter.cs +++ /dev/null @@ -1,142 +0,0 @@ -using System; -using System.Collections.Generic; -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 Dictionary - { - ["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.Substring(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.Substring(prefix.Length); - string escapedValue = EscapeQuotes(value); - string expression = $"{keyword}({attributeName},'{escapedValue}')"; - - return (OutputParameterName, expression); - } - } - - if (parameterValue.StartsWith(NotEqualsPrefix, StringComparison.Ordinal)) - { - string value = parameterValue.Substring(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.Substring(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.Substring(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/NullsQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/NullsQueryStringParameterReader.cs deleted file mode 100644 index 3f20c6ba2a..0000000000 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/NullsQueryStringParameterReader.cs +++ /dev/null @@ -1,54 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers.Annotations; -using JsonApiDotNetCore.Errors; -using Microsoft.Extensions.Primitives; -using Newtonsoft.Json; - -namespace JsonApiDotNetCore.QueryStrings.Internal -{ - /// - [PublicAPI] - public class NullsQueryStringParameterReader : INullsQueryStringParameterReader - { - private readonly IJsonApiOptions _options; - - /// - public NullValueHandling SerializerNullValueHandling { get; private set; } - - public NullsQueryStringParameterReader(IJsonApiOptions options) - { - ArgumentGuard.NotNull(options, nameof(options)); - - _options = options; - SerializerNullValueHandling = options.SerializerSettings.NullValueHandling; - } - - /// - public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) - { - ArgumentGuard.NotNull(disableQueryStringAttribute, nameof(disableQueryStringAttribute)); - - return _options.AllowQueryStringOverrideForSerializerNullValueHandling && - !disableQueryStringAttribute.ContainsParameter(StandardQueryStringParameters.Nulls); - } - - /// - public virtual bool CanRead(string parameterName) - { - return parameterName == "nulls"; - } - - /// - public virtual void Read(string parameterName, StringValues parameterValue) - { - if (!bool.TryParse(parameterValue, out bool result)) - { - throw new InvalidQueryStringParameterException(parameterName, "The specified nulls is invalid.", - $"The value '{parameterValue}' must be 'true' or 'false'."); - } - - SerializerNullValueHandling = result ? NullValueHandling.Include : NullValueHandling.Ignore; - } - } -} diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs deleted file mode 100644 index c9b9813a5c..0000000000 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/PaginationQueryStringParameterReader.cs +++ /dev/null @@ -1,211 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -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 PaginationQueryStringParameterReader(IJsonApiRequest request, IResourceContextProvider resourceContextProvider, IJsonApiOptions options) - : base(request, resourceContextProvider) - { - ArgumentGuard.NotNull(options, nameof(options)); - - _options = options; - _paginationParser = new PaginationParser(resourceContextProvider); - } - - /// - public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) - { - ArgumentGuard.NotNull(disableQueryStringAttribute, nameof(disableQueryStringAttribute)); - - return !IsAtomicOperationsRequest && !disableQueryStringAttribute.ContainsParameter(StandardQueryStringParameters.Page); - } - - /// - public virtual bool CanRead(string parameterName) - { - return parameterName == PageSizeParameterName || parameterName == 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, RequestResource); - } - - 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 context = new PaginationContext(); - - foreach (PaginationElementQueryStringValueExpression element in _pageSizeConstraint?.Elements ?? - Array.Empty()) - { - MutablePaginationEntry entry = context.ResolveEntryInScope(element.Scope); - entry.PageSize = element.Value == 0 ? null : new PageSize(element.Value); - entry.HasSetPageSize = true; - } - - foreach (PaginationElementQueryStringValueExpression element in _pageNumberConstraint?.Elements ?? - Array.Empty()) - { - MutablePaginationEntry entry = context.ResolveEntryInScope(element.Scope); - entry.PageNumber = new PageNumber(element.Value); - } - - context.ApplyOptions(_options); - - return context.GetExpressionsInScope(); - } - - private sealed class PaginationContext - { - private readonly MutablePaginationEntry _globalScope = new MutablePaginationEntry(); - - private readonly Dictionary _nestedScopes = - new Dictionary(); - - 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/QueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringParameterReader.cs deleted file mode 100644 index 79c698627e..0000000000 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringParameterReader.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System; -using System.Linq; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal.Parsing; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.QueryStrings.Internal -{ - public abstract class QueryStringParameterReader - { - private readonly IResourceContextProvider _resourceContextProvider; - private readonly bool _isCollectionRequest; - - protected ResourceContext RequestResource { get; } - protected bool IsAtomicOperationsRequest { get; } - - protected QueryStringParameterReader(IJsonApiRequest request, IResourceContextProvider resourceContextProvider) - { - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider)); - - _resourceContextProvider = resourceContextProvider; - _isCollectionRequest = request.IsCollection; - RequestResource = request.SecondaryResource ?? request.PrimaryResource; - IsAtomicOperationsRequest = request.Kind == EndpointKind.AtomicOperations; - } - - protected ResourceContext GetResourceContextForScope(ResourceFieldChainExpression scope) - { - if (scope == null) - { - return RequestResource; - } - - ResourceFieldAttribute lastField = scope.Fields.Last(); - Type type = lastField is RelationshipAttribute relationship ? relationship.RightType : lastField.Property.PropertyType; - - return _resourceContextProvider.GetResourceContext(type); - } - - 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)."); - } - } - } -} diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringReader.cs deleted file mode 100644 index fe4064a9c2..0000000000 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/QueryStringReader.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers.Annotations; -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) - { - 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/RequestQueryStringAccessor.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/RequestQueryStringAccessor.cs deleted file mode 100644 index d53d01b054..0000000000 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/RequestQueryStringAccessor.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace JsonApiDotNetCore.QueryStrings.Internal -{ - /// - internal sealed class RequestQueryStringAccessor : IRequestQueryStringAccessor - { - private readonly IHttpContextAccessor _httpContextAccessor; - - public IQueryCollection Query => _httpContextAccessor.HttpContext.Request.Query; - - public RequestQueryStringAccessor(IHttpContextAccessor httpContextAccessor) - { - ArgumentGuard.NotNull(httpContextAccessor, nameof(httpContextAccessor)); - - _httpContextAccessor = httpContextAccessor; - } - } -} diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs deleted file mode 100644 index d5eb7a4110..0000000000 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/ResourceDefinitionQueryableParameterReader.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System; -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Controllers.Annotations; -using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources; -using Microsoft.Extensions.Primitives; - -namespace JsonApiDotNetCore.QueryStrings.Internal -{ - /// - [PublicAPI] - public class ResourceDefinitionQueryableParameterReader : IResourceDefinitionQueryableParameterReader - { - private readonly IJsonApiRequest _request; - private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; - private readonly List _constraints = new List(); - - public ResourceDefinitionQueryableParameterReader(IJsonApiRequest request, IResourceDefinitionAccessor resourceDefinitionAccessor) - { - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); - - _request = request; - _resourceDefinitionAccessor = resourceDefinitionAccessor; - } - - /// - public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) - { - return true; - } - - /// - public virtual bool CanRead(string parameterName) - { - if (_request.Kind == EndpointKind.AtomicOperations) - { - return false; - } - - object queryableHandler = GetQueryableHandler(parameterName); - return queryableHandler != null; - } - - /// - public virtual void Read(string parameterName, StringValues parameterValue) - { - object queryableHandler = GetQueryableHandler(parameterName); - var expressionInScope = new ExpressionInScope(null, new QueryableHandlerExpression(queryableHandler, parameterValue)); - _constraints.Add(expressionInScope); - } - - private object GetQueryableHandler(string parameterName) - { - Type resourceType = (_request.SecondaryResource ?? _request.PrimaryResource).ResourceType; - object handler = _resourceDefinitionAccessor.GetQueryableHandlerForQueryStringParameter(resourceType, parameterName); - - if (handler != null && _request.Kind != EndpointKind.Primary) - { - throw new InvalidQueryStringParameterException(parameterName, "Custom query string parameters cannot be used on nested resource endpoints.", - $"Query string parameter '{parameterName}' cannot be used on a nested resource endpoint."); - } - - return handler; - } - - /// - public virtual IReadOnlyCollection GetConstraints() - { - return _constraints; - } - } -} diff --git a/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs deleted file mode 100644 index 058fa6817c..0000000000 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/SortQueryStringParameterReader.cs +++ /dev/null @@ -1,100 +0,0 @@ -using System; -using System.Collections.Generic; -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 List(); - private string _lastParameterName; - - public SortQueryStringParameterReader(IJsonApiRequest request, IResourceContextProvider resourceContextProvider) - : base(request, resourceContextProvider) - { - _scopeParser = new QueryStringParameterScopeParser(resourceContextProvider, FieldChainRequirements.EndsInToMany); - _sortParser = new SortParser(resourceContextProvider, ValidateSingleField); - } - - protected void ValidateSingleField(ResourceFieldAttribute field, ResourceContext resourceContext, 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(StandardQueryStringParameters.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, RequestResource); - - if (parameterScope.Scope == null) - { - AssertIsCollectionRequest(); - } - - return parameterScope.Scope; - } - - private SortExpression GetSort(string parameterValue, ResourceFieldChainExpression scope) - { - ResourceContext resourceContextInScope = GetResourceContextForScope(scope); - return _sortParser.Parse(parameterValue, resourceContextInScope); - } - - /// - 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 f9e804797c..0000000000 --- a/src/JsonApiDotNetCore/QueryStrings/Internal/SparseFieldSetQueryStringParameterReader.cs +++ /dev/null @@ -1,106 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -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 Dictionary _sparseFieldTable = new Dictionary(); - private string _lastParameterName; - - /// - bool IQueryStringParameterReader.AllowEmptyValue => true; - - public SparseFieldSetQueryStringParameterReader(IJsonApiRequest request, IResourceContextProvider resourceContextProvider) - : base(request, resourceContextProvider) - { - _sparseFieldTypeParser = new SparseFieldTypeParser(resourceContextProvider); - _sparseFieldSetParser = new SparseFieldSetParser(resourceContextProvider, ValidateSingleField); - } - - protected void ValidateSingleField(ResourceFieldAttribute field, ResourceContext resourceContext, 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(StandardQueryStringParameters.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 - { - ResourceContext targetResource = GetSparseFieldType(parameterName); - SparseFieldSetExpression sparseFieldSet = GetSparseFieldSet(parameterValue, targetResource); - - _sparseFieldTable[targetResource] = sparseFieldSet; - } - catch (QueryParseException exception) - { - throw new InvalidQueryStringParameterException(parameterName, "The specified fieldset is invalid.", exception.Message, exception); - } - } - - private ResourceContext GetSparseFieldType(string parameterName) - { - return _sparseFieldTypeParser.Parse(parameterName); - } - - private SparseFieldSetExpression GetSparseFieldSet(string parameterValue, ResourceContext resourceContext) - { - SparseFieldSetExpression sparseFieldSet = _sparseFieldSetParser.Parse(parameterValue, resourceContext); - - 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 = resourceContext.Attributes.Single(attribute => attribute.Property.Name == nameof(Identifiable.Id)); - return new SparseFieldSetExpression(ArrayFactory.Create(idAttribute)); - } - - return sparseFieldSet; - } - - /// - public virtual IReadOnlyCollection GetConstraints() - { - return _sparseFieldTable.Any() - ? new ExpressionInScope(null, new SparseFieldTableExpression(_sparseFieldTable)).AsArray() - : Array.Empty(); - } - } -} diff --git a/src/JsonApiDotNetCore/QueryStrings/JsonApiQueryStringParameters.cs b/src/JsonApiDotNetCore/QueryStrings/JsonApiQueryStringParameters.cs new file mode 100644 index 0000000000..e7c418d8a7 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/JsonApiQueryStringParameters.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Controllers.Annotations; + +namespace JsonApiDotNetCore.QueryStrings; + +/// +/// Lists query string parameters used by . +/// +[Flags] +public enum JsonApiQueryStringParameters +{ + None = 0, + Filter = 1, + 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/QueryStringParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/QueryStringParameterReader.cs new file mode 100644 index 0000000000..1b59eed083 --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/QueryStringParameterReader.cs @@ -0,0 +1,53 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.QueryStrings; + +public abstract class QueryStringParameterReader +{ + private readonly IResourceGraph _resourceGraph; + private readonly bool _isCollectionRequest; + + protected ResourceType RequestResourceType { get; } + protected bool IsAtomicOperationsRequest { get; } + + protected QueryStringParameterReader(IJsonApiRequest request, IResourceGraph resourceGraph) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(resourceGraph); + + _resourceGraph = resourceGraph; + _isCollectionRequest = request.IsCollection; + // There are currently no query string readers that work with operations, so non-nullable for convenience. + RequestResourceType = (request.SecondaryResourceType ?? request.PrimaryResourceType)!; + IsAtomicOperationsRequest = request.Kind == EndpointKind.AtomicOperations; + } + + protected ResourceType GetResourceTypeForScope(ResourceFieldChainExpression? scope) + { + if (scope == null) + { + return RequestResourceType; + } + + ResourceFieldAttribute lastField = scope.Fields[^1]; + + if (lastField is RelationshipAttribute relationship) + { + return relationship.RightType; + } + + return _resourceGraph.GetResourceType(lastField.Property.PropertyType); + } + + 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).", 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/RequestQueryStringAccessor.cs b/src/JsonApiDotNetCore/QueryStrings/RequestQueryStringAccessor.cs new file mode 100644 index 0000000000..92b9a0a60a --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/RequestQueryStringAccessor.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Http; + +namespace JsonApiDotNetCore.QueryStrings; + +/// +internal sealed class RequestQueryStringAccessor : IRequestQueryStringAccessor +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + public IQueryCollection Query + { + get + { + if (_httpContextAccessor.HttpContext == null) + { + throw new InvalidOperationException("An active HTTP request is required."); + } + + return _httpContextAccessor.HttpContext.Request.Query; + } + } + + public RequestQueryStringAccessor(IHttpContextAccessor httpContextAccessor) + { + ArgumentNullException.ThrowIfNull(httpContextAccessor); + + _httpContextAccessor = httpContextAccessor; + } +} diff --git a/src/JsonApiDotNetCore/QueryStrings/ResourceDefinitionQueryableParameterReader.cs b/src/JsonApiDotNetCore/QueryStrings/ResourceDefinitionQueryableParameterReader.cs new file mode 100644 index 0000000000..fb52e5775d --- /dev/null +++ b/src/JsonApiDotNetCore/QueryStrings/ResourceDefinitionQueryableParameterReader.cs @@ -0,0 +1,78 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Controllers.Annotations; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources; +using Microsoft.Extensions.Primitives; + +namespace JsonApiDotNetCore.QueryStrings; + +/// +[PublicAPI] +public class ResourceDefinitionQueryableParameterReader : IResourceDefinitionQueryableParameterReader +{ + private readonly IJsonApiRequest _request; + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; + private readonly List _constraints = []; + + public bool AllowEmptyValue => false; + + public ResourceDefinitionQueryableParameterReader(IJsonApiRequest request, IResourceDefinitionAccessor resourceDefinitionAccessor) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(resourceDefinitionAccessor); + + _request = request; + _resourceDefinitionAccessor = resourceDefinitionAccessor; + } + + /// + public virtual bool IsEnabled(DisableQueryStringAttribute disableQueryStringAttribute) + { + return true; + } + + /// + public virtual bool CanRead(string parameterName) + { + ArgumentException.ThrowIfNullOrEmpty(parameterName); + + if (_request.Kind == EndpointKind.AtomicOperations) + { + return false; + } + + object? queryableHandler = GetQueryableHandler(parameterName); + return queryableHandler != null; + } + + /// + public virtual void Read(string parameterName, StringValues parameterValue) + { + object queryableHandler = GetQueryableHandler(parameterName)!; + var expressionInScope = new ExpressionInScope(null, new QueryableHandlerExpression(queryableHandler, parameterValue)); + _constraints.Add(expressionInScope); + } + + private object? GetQueryableHandler(string parameterName) + { + Type resourceClrType = (_request.SecondaryResourceType ?? _request.PrimaryResourceType)!.ClrType; + object? handler = _resourceDefinitionAccessor.GetQueryableHandlerForQueryStringParameter(resourceClrType, parameterName); + + if (handler != null && _request.Kind != EndpointKind.Primary) + { + throw new InvalidQueryStringParameterException(parameterName, "Custom query string parameters cannot be used on nested resource endpoints.", + $"Query string parameter '{parameterName}' cannot be used on a nested resource endpoint."); + } + + return handler; + } + + /// + public virtual IReadOnlyCollection GetConstraints() + { + 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/QueryStrings/StandardQueryStringParameters.cs b/src/JsonApiDotNetCore/QueryStrings/StandardQueryStringParameters.cs deleted file mode 100644 index 521a9af37b..0000000000 --- a/src/JsonApiDotNetCore/QueryStrings/StandardQueryStringParameters.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; -using JsonApiDotNetCore.Controllers.Annotations; - -namespace JsonApiDotNetCore.QueryStrings -{ - /// - /// Lists query string parameters used by . - /// - [Flags] - public enum StandardQueryStringParameters - { - None = 0, - Filter = 1, - Sort = 2, - Include = 4, - Page = 8, - Fields = 16, - Nulls = 32, - Defaults = 64, - All = Filter | Sort | Include | Page | Fields | Nulls | Defaults - } -} diff --git a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs index 80a1b85a77..9fb9b482cd 100644 --- a/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs +++ b/src/JsonApiDotNetCore/Repositories/DataStoreUpdateException.cs @@ -1,17 +1,10 @@ -using System; using JetBrains.Annotations; -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 exception) - : base("Failed to persist changes in the underlying data store.", exception) - { - } - } -} +namespace JsonApiDotNetCore.Repositories; + +/// +/// The error that is thrown when the underlying data store is unable to persist changes. +/// +[PublicAPI] +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 a6d95560a2..0e7089acec 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextExtensions.cs @@ -1,69 +1,60 @@ -using System; -using System.Collections.Generic; -using System.Linq; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; -namespace JsonApiDotNetCore.Repositories +namespace JsonApiDotNetCore.Repositories; + +[PublicAPI] +public static class DbContextExtensions { - [PublicAPI] - public static class DbContextExtensions + /// + /// If not already tracked, attaches the specified resource to the change tracker in state. + /// + public static IIdentifiable GetTrackedOrAttach(this DbContext dbContext, IIdentifiable resource) { - /// - /// If not already tracked, attaches the specified resource to the change tracker in state. - /// - 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); + var trackedIdentifiable = (IIdentifiable?)dbContext.GetTrackedIdentifiable(resource); - if (trackedIdentifiable == null) - { - dbContext.Entry(resource).State = EntityState.Unchanged; - trackedIdentifiable = resource; - } - - return trackedIdentifiable; + if (trackedIdentifiable == null) + { + dbContext.Entry(resource).State = EntityState.Unchanged; + trackedIdentifiable = resource; } - /// - /// Searches the change tracker for an entity that matches the type and ID of . - /// - public static object GetTrackedIdentifiable(this DbContext dbContext, IIdentifiable identifiable) - { - ArgumentGuard.NotNull(dbContext, nameof(dbContext)); - ArgumentGuard.NotNull(identifiable, nameof(identifiable)); + return trackedIdentifiable; + } - Type resourceType = identifiable.GetType(); - string stringId = identifiable.StringId; + /// + /// Searches the change tracker for an entity that matches the type and ID of . + /// + public static object? GetTrackedIdentifiable(this DbContext dbContext, IIdentifiable identifiable) + { + ArgumentNullException.ThrowIfNull(dbContext); + ArgumentNullException.ThrowIfNull(identifiable); - EntityEntry entityEntry = dbContext.ChangeTracker.Entries().FirstOrDefault(entry => IsResource(entry, resourceType, stringId)); + Type resourceClrType = identifiable.GetClrType(); + string? stringId = identifiable.StringId; - return entityEntry?.Entity; - } + EntityEntry? entityEntry = dbContext.ChangeTracker.Entries().FirstOrDefault(entry => IsResource(entry, resourceClrType, stringId)); - private static bool IsResource(EntityEntry entry, Type resourceType, string stringId) - { - return entry.Entity.GetType() == resourceType && ((IIdentifiable)entry.Entity).StringId == stringId; - } + return entityEntry?.Entity; + } - /// - /// Detaches all entities from the change tracker. - /// - public static void ResetChangeTracker(this DbContext dbContext) - { - ArgumentGuard.NotNull(dbContext, nameof(dbContext)); + private static bool IsResource(EntityEntry entry, Type resourceClrType, string? stringId) + { + return entry.Entity.GetType() == resourceClrType && ((IIdentifiable)entry.Entity).StringId == stringId; + } - List entriesWithChanges = dbContext.ChangeTracker.Entries().ToList(); + /// + /// Detaches all entities from the change tracker. + /// + public static void ResetChangeTracker(this DbContext dbContext) + { + ArgumentNullException.ThrowIfNull(dbContext); - foreach (EntityEntry entry in entriesWithChanges) - { - entry.State = EntityState.Detached; - } - } + dbContext.ChangeTracker.Clear(); } } diff --git a/src/JsonApiDotNetCore/Repositories/DbContextResolver.cs b/src/JsonApiDotNetCore/Repositories/DbContextResolver.cs index 4e1c7b4552..c48169df72 100644 --- a/src/JsonApiDotNetCore/Repositories/DbContextResolver.cs +++ b/src/JsonApiDotNetCore/Repositories/DbContextResolver.cs @@ -1,30 +1,32 @@ using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCore.Repositories +namespace JsonApiDotNetCore.Repositories; + +/// +/// +/// The type of the to resolve. +/// +[PublicAPI] +public sealed class DbContextResolver : IDbContextResolver + where TDbContext : DbContext { - /// - [PublicAPI] - public sealed class DbContextResolver : IDbContextResolver - where TDbContext : DbContext - { - private readonly TDbContext _context; + private readonly TDbContext _dbContext; - public DbContextResolver(TDbContext context) - { - ArgumentGuard.NotNull(context, nameof(context)); + public DbContextResolver(TDbContext dbContext) + { + ArgumentNullException.ThrowIfNull(dbContext); - _context = context; - } + _dbContext = dbContext; + } - public DbContext GetContext() - { - return _context; - } + public DbContext GetContext() + { + return _dbContext; + } - public TDbContext GetTypedContext() - { - return _context; - } + public TDbContext GetTypedContext() + { + return _dbContext; } } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs index 397eea4a5b..f4c9af37c0 100644 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs @@ -1,17 +1,14 @@ -using System; using System.Collections; -using System.Collections.Generic; -using System.Linq; +using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Diagnostics; using JsonApiDotNetCore.Errors; 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; @@ -19,103 +16,120 @@ using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.Extensions.Logging; -namespace JsonApiDotNetCore.Repositories +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 { - /// - /// Implements the foundational Repository layer in the JsonApiDotNetCore architecture that uses Entity Framework Core. - /// - [PublicAPI] - public class EntityFrameworkCoreRepository : IResourceRepository, IRepositorySupportsTransaction - where TResource : class, IIdentifiable + private readonly ITargetedFields _targetedFields; + private readonly DbContext _dbContext; + private readonly IResourceGraph _resourceGraph; + private readonly IResourceFactory _resourceFactory; + private readonly IQueryConstraintProvider[] _constraintProviders; + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; + private readonly TraceLogWriter> _traceWriter; + + /// + public virtual string? TransactionId => _dbContext.Database.CurrentTransaction?.TransactionId.ToString(); + + public EntityFrameworkCoreRepository(ITargetedFields targetedFields, IDbContextResolver dbContextResolver, IResourceGraph resourceGraph, + IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory, + IResourceDefinitionAccessor resourceDefinitionAccessor) { - private readonly CollectionConverter _collectionConverter = new CollectionConverter(); - private readonly ITargetedFields _targetedFields; - private readonly DbContext _dbContext; - private readonly IResourceGraph _resourceGraph; - private readonly IResourceFactory _resourceFactory; - private readonly IEnumerable _constraintProviders; - private readonly TraceLogWriter> _traceWriter; - private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; - - /// - public virtual string TransactionId => _dbContext.Database.CurrentTransaction?.TransactionId.ToString(); - - public EntityFrameworkCoreRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory) - { - ArgumentGuard.NotNull(contextResolver, nameof(contextResolver)); - ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); - ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - ArgumentGuard.NotNull(resourceFactory, nameof(resourceFactory)); - ArgumentGuard.NotNull(constraintProviders, nameof(constraintProviders)); - - _targetedFields = targetedFields; - _resourceGraph = resourceGraph; - _resourceFactory = resourceFactory; - _constraintProviders = constraintProviders; - _dbContext = contextResolver.GetContext(); - _traceWriter = new TraceLogWriter>(loggerFactory); - -#pragma warning disable 612 // Method is obsolete - _resourceDefinitionAccessor = resourceFactory.GetResourceDefinitionAccessor(); -#pragma warning restore 612 - } + 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 as IQueryConstraintProvider[] ?? constraintProviders.ToArray(); + _resourceDefinitionAccessor = resourceDefinitionAccessor; + _traceWriter = new TraceLogWriter>(loggerFactory); + } - /// - public virtual async Task> GetAsync(QueryLayer layer, CancellationToken cancellationToken) + /// + public virtual async Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - _traceWriter.LogMethodStart(new - { - layer - }); + queryLayer + }); - ArgumentGuard.NotNull(layer, nameof(layer)); + ArgumentNullException.ThrowIfNull(queryLayer); - IQueryable query = ApplyQueryLayer(layer); - return await query.ToListAsync(cancellationToken); + using (CodeTimingSessionManager.Current.Measure("Repository - Get resource(s)")) + { + IQueryable query = ApplyQueryLayer(queryLayer); + + using (CodeTimingSessionManager.Current.Measure("Execute SQL (data)", MeasurementSettings.ExcludeDatabaseInPercentages)) + { + List resources = await query.ToListAsync(cancellationToken); + return resources.AsReadOnly(); + } } + } - /// - public virtual async Task CountAsync(FilterExpression topFilter, CancellationToken cancellationToken) + /// + public virtual async Task CountAsync(FilterExpression? filter, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - _traceWriter.LogMethodStart(new - { - topFilter - }); + filter + }); - ResourceContext resourceContext = _resourceGraph.GetResourceContext(); + using (CodeTimingSessionManager.Current.Measure("Repository - Count resources")) + { + ResourceType resourceType = _resourceGraph.GetResourceType(); - var layer = new QueryLayer(resourceContext) + var layer = new QueryLayer(resourceType) { - Filter = topFilter + Filter = filter }; IQueryable query = ApplyQueryLayer(layer); - return await query.CountAsync(cancellationToken); - } - protected virtual IQueryable ApplyQueryLayer(QueryLayer layer) - { - _traceWriter.LogMethodStart(new + using (CodeTimingSessionManager.Current.Measure("Execute SQL (count)", MeasurementSettings.ExcludeDatabaseInPercentages)) { - layer - }); + return await query.CountAsync(cancellationToken); + } + } + } - ArgumentGuard.NotNull(layer, nameof(layer)); + protected virtual IQueryable ApplyQueryLayer(QueryLayer queryLayer) + { + ArgumentNullException.ThrowIfNull(queryLayer); - QueryLayer rewrittenLayer = layer; + _traceWriter.LogMethodStart(new + { + queryLayer + }); - if (EntityFrameworkCoreSupport.Version.Major < 5) - { - var writer = new MemoryLeakDetectionBugRewriter(); - rewrittenLayer = writer.Rewrite(layer); - } + 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()) @@ -124,7 +138,7 @@ protected virtual IQueryable ApplyQueryLayer(QueryLayer layer) .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) @@ -132,427 +146,527 @@ protected virtual IQueryable ApplyQueryLayer(QueryLayer layer) 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, _resourceGraph, - _dbContext.Model); + var context = QueryableBuilderContext.CreateRoot(source, typeof(Queryable), _dbContext.Model, null); + Expression expression = builder.ApplyQuery(queryLayer, context); - Expression expression = builder.ApplyQuery(rewrittenLayer); - return source.Provider.CreateQuery(expression); - } + _traceWriter.LogDebug(expression); - protected virtual IQueryable GetAll() - { - return _dbContext.Set(); + using (CodeTimingSessionManager.Current.Measure("Convert System.Expression to IQueryable")) + { + return source.Provider.CreateQuery(expression); + } } + } + + protected virtual IQueryable GetAll() + { + IQueryable source = _dbContext.Set(); - /// - public virtual Task GetForCreateAsync(TId id, CancellationToken cancellationToken) + return GetTrackingBehavior() switch { - var resource = _resourceFactory.CreateInstance(); - resource.Id = id; + QueryTrackingBehavior.NoTrackingWithIdentityResolution => source.AsNoTrackingWithIdentityResolution(), + QueryTrackingBehavior.NoTracking => source.AsNoTracking(), + QueryTrackingBehavior.TrackAll => source.AsTracking(), + _ => source + }; + } - return Task.FromResult(resource); - } + 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 async Task CreateAsync(TResource resourceFromRequest, TResource resourceForDatabase, CancellationToken cancellationToken) + /// + public virtual Task GetForCreateAsync(Type resourceClrType, [DisallowNull] TId id, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - _traceWriter.LogMethodStart(new - { - resourceFromRequest, - resourceForDatabase - }); - - ArgumentGuard.NotNull(resourceFromRequest, nameof(resourceFromRequest)); - ArgumentGuard.NotNull(resourceForDatabase, nameof(resourceForDatabase)); + resourceClrType, + id + }); - using var collector = new PlaceholderResourceCollector(_resourceFactory, _dbContext); + ArgumentNullException.ThrowIfNull(resourceClrType); - foreach (RelationshipAttribute relationship in _targetedFields.Relationships) - { - object rightResources = relationship.GetValue(resourceFromRequest); + var resource = (TResource)_resourceFactory.CreateInstance(resourceClrType); + resource.Id = id; - object rightResourcesEdited = await VisitSetRelationshipAsync(resourceForDatabase, relationship, rightResources, OperationKind.CreateResource, - cancellationToken); + return Task.FromResult(resource); + } - await UpdateRelationshipAsync(relationship, resourceForDatabase, rightResourcesEdited, collector, cancellationToken); - } + /// + public virtual async Task CreateAsync(TResource resourceFromRequest, TResource resourceForDatabase, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + resourceFromRequest, + resourceForDatabase + }); - foreach (AttrAttribute attribute in _targetedFields.Attributes) - { - attribute.SetValue(resourceForDatabase, attribute.GetValue(resourceFromRequest)); - } + ArgumentNullException.ThrowIfNull(resourceFromRequest); + ArgumentNullException.ThrowIfNull(resourceForDatabase); - await _resourceDefinitionAccessor.OnWritingAsync(resourceForDatabase, OperationKind.CreateResource, cancellationToken); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Create resource"); - DbSet dbSet = _dbContext.Set(); - await dbSet.AddAsync(resourceForDatabase, cancellationToken); + foreach (RelationshipAttribute relationship in _targetedFields.Relationships) + { + object? rightValue = relationship.GetValue(resourceFromRequest); - await SaveChangesAsync(cancellationToken); + object? rightValueEvaluated = await VisitSetRelationshipAsync(resourceForDatabase, relationship, rightValue, WriteOperationKind.CreateResource, + cancellationToken); - await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceForDatabase, OperationKind.CreateResource, cancellationToken); + await UpdateRelationshipAsync(relationship, resourceForDatabase, rightValueEvaluated, cancellationToken); } - private async Task VisitSetRelationshipAsync(TResource leftResource, RelationshipAttribute relationship, object rightResourceIds, - OperationKind operationKind, CancellationToken cancellationToken) + foreach (AttrAttribute attribute in _targetedFields.Attributes) { - if (relationship is HasOneAttribute hasOneRelationship) - { - return await _resourceDefinitionAccessor.OnSetToOneRelationshipAsync(leftResource, hasOneRelationship, (IIdentifiable)rightResourceIds, - operationKind, cancellationToken); - } + attribute.SetValue(resourceForDatabase, attribute.GetValue(resourceFromRequest)); + } - if (relationship is HasManyAttribute hasManyRelationship) - { - HashSet rightResourceIdSet = _collectionConverter.ExtractResources(rightResourceIds).ToHashSet(IdentifiableComparer.Instance); + await _resourceDefinitionAccessor.OnWritingAsync(resourceForDatabase, WriteOperationKind.CreateResource, cancellationToken); - await _resourceDefinitionAccessor.OnSetToManyRelationshipAsync(leftResource, hasManyRelationship, rightResourceIdSet, operationKind, - cancellationToken); + DbSet dbSet = _dbContext.Set(); + await dbSet.AddAsync(resourceForDatabase, cancellationToken); - return rightResourceIdSet; - } + await SaveChangesAsync(cancellationToken); - return rightResourceIds; + await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceForDatabase, WriteOperationKind.CreateResource, cancellationToken); + + _dbContext.ResetChangeTracker(); + } + + 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); } - /// - public virtual async Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + if (relationship is HasManyAttribute hasManyRelationship) { - IReadOnlyCollection resources = await GetAsync(queryLayer, cancellationToken); - return resources.FirstOrDefault(); + HashSet rightResourceIds = CollectionConverter.Instance.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); + + await _resourceDefinitionAccessor.OnSetToManyRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, writeOperation, + cancellationToken); + + return rightResourceIds; } - /// - public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource resourceFromDatabase, CancellationToken cancellationToken) + return rightValue; + } + + /// + public virtual async Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - _traceWriter.LogMethodStart(new - { - resourceFromRequest, - resourceFromDatabase - }); + queryLayer + }); - ArgumentGuard.NotNull(resourceFromRequest, nameof(resourceFromRequest)); - ArgumentGuard.NotNull(resourceFromDatabase, nameof(resourceFromDatabase)); + ArgumentNullException.ThrowIfNull(queryLayer); - using var collector = new PlaceholderResourceCollector(_resourceFactory, _dbContext); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Get resource for update"); - foreach (RelationshipAttribute relationship in _targetedFields.Relationships) - { - object rightResources = relationship.GetValue(resourceFromRequest); + IReadOnlyCollection resources = await GetAsync(queryLayer, cancellationToken); + return resources.FirstOrDefault(); + } - object rightResourcesEdited = await VisitSetRelationshipAsync(resourceFromDatabase, relationship, rightResources, OperationKind.UpdateResource, - cancellationToken); + /// + public virtual async Task UpdateAsync(TResource resourceFromRequest, TResource resourceFromDatabase, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + resourceFromRequest, + resourceFromDatabase + }); - AssertIsNotClearingRequiredRelationship(relationship, resourceFromDatabase, rightResourcesEdited); + ArgumentNullException.ThrowIfNull(resourceFromRequest); + ArgumentNullException.ThrowIfNull(resourceFromDatabase); - await UpdateRelationshipAsync(relationship, resourceFromDatabase, rightResourcesEdited, collector, cancellationToken); - } + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Update resource"); - foreach (AttrAttribute attribute in _targetedFields.Attributes) - { - attribute.SetValue(resourceFromDatabase, attribute.GetValue(resourceFromRequest)); - } + foreach (RelationshipAttribute relationship in _targetedFields.Relationships) + { + object? rightValue = relationship.GetValue(resourceFromRequest); - await _resourceDefinitionAccessor.OnWritingAsync(resourceFromDatabase, OperationKind.UpdateResource, cancellationToken); + object? rightValueEvaluated = await VisitSetRelationshipAsync(resourceFromDatabase, relationship, rightValue, WriteOperationKind.UpdateResource, + cancellationToken); - await SaveChangesAsync(cancellationToken); + AssertIsNotClearingRequiredToOneRelationship(relationship, rightValueEvaluated); - await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceFromDatabase, OperationKind.UpdateResource, cancellationToken); + await UpdateRelationshipAsync(relationship, resourceFromDatabase, rightValueEvaluated, cancellationToken); } - protected void AssertIsNotClearingRequiredRelationship(RelationshipAttribute relationship, TResource leftResource, object rightValue) + foreach (AttrAttribute attribute in _targetedFields.Attributes) { - bool relationshipIsRequired = false; + attribute.SetValue(resourceFromDatabase, attribute.GetValue(resourceFromRequest)); + } - if (!(relationship is HasManyThroughAttribute)) - { - INavigation navigation = TryGetNavigation(relationship); - relationshipIsRequired = navigation?.ForeignKey?.IsRequired ?? false; - } + await _resourceDefinitionAccessor.OnWritingAsync(resourceFromDatabase, WriteOperationKind.UpdateResource, cancellationToken); - bool relationshipIsBeingCleared = relationship is HasManyAttribute hasManyRelationship - ? IsToManyRelationshipBeingCleared(hasManyRelationship, leftResource, rightValue) - : rightValue == null; + await SaveChangesAsync(cancellationToken); - if (relationshipIsRequired && relationshipIsBeingCleared) + await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceFromDatabase, WriteOperationKind.UpdateResource, cancellationToken); + + _dbContext.ResetChangeTracker(); + } + + protected void AssertIsNotClearingRequiredToOneRelationship(RelationshipAttribute relationship, object? rightValue) + { + ArgumentNullException.ThrowIfNull(relationship); + + if (relationship is HasOneAttribute) + { + INavigation? navigation = GetNavigation(relationship); + bool isRelationshipRequired = navigation?.ForeignKey.IsRequired ?? false; + + bool isClearingRelationship = rightValue == null; + + if (isRelationshipRequired && isClearingRelationship) { - string resourceType = _resourceGraph.GetResourceContext().PublicName; - throw new CannotClearRequiredRelationshipException(relationship.PublicName, leftResource.StringId, resourceType); + string resourceName = _resourceGraph.GetResourceType().PublicName; + throw new CannotClearRequiredRelationshipException(relationship.PublicName, resourceName); } } + } - private bool IsToManyRelationshipBeingCleared(HasManyAttribute hasManyRelationship, TResource leftResource, object valueToAssign) + /// + public virtual async Task DeleteAsync(TResource? resourceFromDatabase, [DisallowNull] TId id, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - ICollection newRightResourceIds = _collectionConverter.ExtractResources(valueToAssign); + resourceFromDatabase, + id + }); - object existingRightValue = hasManyRelationship.GetValue(leftResource); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Delete resource"); - HashSet existingRightResourceIds = - _collectionConverter.ExtractResources(existingRightValue).ToHashSet(IdentifiableComparer.Instance); + // This enables OnWritingAsync() to fetch the resource, which adds it to the change tracker. + // If so, we'll reuse the tracked resource instead of this placeholder resource. + TResource placeholderResource = resourceFromDatabase ?? _resourceFactory.CreateInstance(); + placeholderResource.Id = id; - existingRightResourceIds.ExceptWith(newRightResourceIds); + await _resourceDefinitionAccessor.OnWritingAsync(placeholderResource, WriteOperationKind.DeleteResource, cancellationToken); - return existingRightResourceIds.Any(); - } + var resourceTracked = (TResource)_dbContext.GetTrackedOrAttach(placeholderResource); - /// - public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToken) + foreach (RelationshipAttribute relationship in _resourceGraph.GetResourceType().Relationships) { - _traceWriter.LogMethodStart(new + // Loads the data of the relationship, if in Entity Framework Core it is configured in such a way that loading + // the related entities into memory is required for successfully executing the selected deletion behavior. + if (RequiresLoadOfRelationshipForDeletion(relationship)) { - id - }); + NavigationEntry navigation = GetNavigationEntry(resourceTracked, relationship); + await navigation.LoadAsync(cancellationToken); + } + } - // This enables OnWritingAsync() to fetch the resource, which adds it to the change tracker. - // If so, we'll reuse the tracked resource instead of a placeholder resource. - var emptyResource = _resourceFactory.CreateInstance(); - emptyResource.Id = id; + _dbContext.Remove(resourceTracked); - await _resourceDefinitionAccessor.OnWritingAsync(emptyResource, OperationKind.DeleteResource, cancellationToken); + await SaveChangesAsync(cancellationToken); - using var collector = new PlaceholderResourceCollector(_resourceFactory, _dbContext); - TResource resource = collector.CreateForId(id); + await _resourceDefinitionAccessor.OnWriteSucceededAsync(resourceTracked, WriteOperationKind.DeleteResource, cancellationToken); + } - foreach (RelationshipAttribute relationship in _resourceGraph.GetRelationships()) - { - // Loads the data of the relationship, if in EF Core it is configured in such a way that loading the related - // entities into memory is required for successfully executing the selected deletion behavior. - if (RequiresLoadOfRelationshipForDeletion(relationship)) - { - NavigationEntry navigation = GetNavigationEntry(resource, relationship); - await navigation.LoadAsync(cancellationToken); - } - } + private NavigationEntry GetNavigationEntry(TResource resource, RelationshipAttribute relationship) + { + EntityEntry entityEntry = _dbContext.Entry(resource); - _dbContext.Remove(resource); + return relationship switch + { + HasOneAttribute hasOneRelationship => entityEntry.Reference(hasOneRelationship.Property.Name), + HasManyAttribute hasManyRelationship => entityEntry.Collection(hasManyRelationship.Property.Name), + _ => throw new InvalidOperationException($"Unknown relationship type '{relationship.GetType().Name}'.") + }; + } - await SaveChangesAsync(cancellationToken); + private bool RequiresLoadOfRelationshipForDeletion(RelationshipAttribute relationship) + { + INavigation? navigation = GetNavigation(relationship); + bool isClearOfForeignKeyRequired = navigation?.ForeignKey.DeleteBehavior == DeleteBehavior.ClientSetNull; - await _resourceDefinitionAccessor.OnWriteSucceededAsync(resource, OperationKind.DeleteResource, cancellationToken); - } + bool hasForeignKeyAtLeftSide = HasForeignKeyAtLeftSide(relationship, navigation); - private NavigationEntry GetNavigationEntry(TResource resource, RelationshipAttribute relationship) - { - EntityEntry entityEntry = _dbContext.Entry(resource); + return isClearOfForeignKeyRequired && !hasForeignKeyAtLeftSide; + } - switch (relationship) - { - 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}'."); - } - } - } + private INavigation? GetNavigation(RelationshipAttribute relationship) + { + IEntityType? entityType = _dbContext.Model.FindEntityType(typeof(TResource)); + return entityType?.FindNavigation(relationship.Property.Name); + } - private bool RequiresLoadOfRelationshipForDeletion(RelationshipAttribute relationship) + private bool HasForeignKeyAtLeftSide(RelationshipAttribute relationship, INavigation? navigation) + { + return relationship is HasOneAttribute && navigation is { IsOnDependent: true }; + } + + /// + public virtual async Task SetRelationshipAsync(TResource leftResource, object? rightValue, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - INavigation navigation = TryGetNavigation(relationship); - bool isClearOfForeignKeyRequired = navigation?.ForeignKey.DeleteBehavior == DeleteBehavior.ClientSetNull; + leftResource, + rightValue + }); - bool hasForeignKeyAtLeftSide = HasForeignKeyAtLeftSide(relationship); + ArgumentNullException.ThrowIfNull(leftResource); - return isClearOfForeignKeyRequired && !hasForeignKeyAtLeftSide; - } + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Set relationship"); - private INavigation TryGetNavigation(RelationshipAttribute relationship) - { - IEntityType entityType = _dbContext.Model.FindEntityType(typeof(TResource)); - return entityType?.FindNavigation(relationship.Property.Name); - } + RelationshipAttribute relationship = _targetedFields.Relationships.Single(); - private bool HasForeignKeyAtLeftSide(RelationshipAttribute relationship) - { - if (relationship is HasOneAttribute) - { - INavigation navigation = TryGetNavigation(relationship); - return navigation?.IsDependentToPrincipal() ?? false; - } + object? rightValueEvaluated = + await VisitSetRelationshipAsync(leftResource, relationship, rightValue, WriteOperationKind.SetRelationship, cancellationToken); - return false; - } + AssertIsNotClearingRequiredToOneRelationship(relationship, rightValueEvaluated); + + await UpdateRelationshipAsync(relationship, leftResource, rightValueEvaluated, cancellationToken); - /// - public virtual async Task SetRelationshipAsync(TResource primaryResource, object secondaryResourceIds, CancellationToken cancellationToken) + await _resourceDefinitionAccessor.OnWritingAsync(leftResource, WriteOperationKind.SetRelationship, cancellationToken); + + await SaveChangesAsync(cancellationToken); + + await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResource, WriteOperationKind.SetRelationship, cancellationToken); + } + + /// + public virtual async Task AddToToManyRelationshipAsync(TResource? leftResource, [DisallowNull] TId leftId, ISet rightResourceIds, + CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - _traceWriter.LogMethodStart(new - { - primaryResource, - secondaryResourceIds - }); + leftResource, + leftId, + rightResourceIds + }); - RelationshipAttribute relationship = _targetedFields.Relationships.Single(); + ArgumentNullException.ThrowIfNull(rightResourceIds); - object secondaryResourceIdsEdited = - await VisitSetRelationshipAsync(primaryResource, relationship, secondaryResourceIds, OperationKind.SetRelationship, cancellationToken); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Add to to-many relationship"); - AssertIsNotClearingRequiredRelationship(relationship, primaryResource, secondaryResourceIdsEdited); + var relationship = (HasManyAttribute)_targetedFields.Relationships.Single(); - using var collector = new PlaceholderResourceCollector(_resourceFactory, _dbContext); - await UpdateRelationshipAsync(relationship, primaryResource, secondaryResourceIdsEdited, collector, cancellationToken); + // This enables OnAddToRelationshipAsync() or OnWritingAsync() to fetch the resource, which adds it to the change tracker. + // If so, we'll reuse the tracked resource instead of this placeholder resource. + TResource leftPlaceholderResource = leftResource ?? _resourceFactory.CreateInstance(); + leftPlaceholderResource.Id = leftId; - await _resourceDefinitionAccessor.OnWritingAsync(primaryResource, OperationKind.SetRelationship, cancellationToken); + await _resourceDefinitionAccessor.OnAddToRelationshipAsync(leftPlaceholderResource, relationship, rightResourceIds, cancellationToken); + + if (rightResourceIds.Count > 0) + { + var leftResourceTracked = (TResource)_dbContext.GetTrackedOrAttach(leftPlaceholderResource); + ISet rightValueToStore = GetRightValueToStoreForAddToToMany(leftResourceTracked, relationship, rightResourceIds); + + await UpdateRelationshipAsync(relationship, leftResourceTracked, rightValueToStore, cancellationToken); + + await _resourceDefinitionAccessor.OnWritingAsync(leftResourceTracked, WriteOperationKind.AddToRelationship, cancellationToken); + leftResourceTracked = (TResource)_dbContext.GetTrackedOrAttach(leftResourceTracked); await SaveChangesAsync(cancellationToken); - await _resourceDefinitionAccessor.OnWriteSucceededAsync(primaryResource, OperationKind.SetRelationship, cancellationToken); + await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResourceTracked, WriteOperationKind.AddToRelationship, cancellationToken); } + } - /// - public virtual async Task AddToToManyRelationshipAsync(TId primaryId, ISet secondaryResourceIds, CancellationToken cancellationToken) - { - _traceWriter.LogMethodStart(new - { - primaryId, - secondaryResourceIds - }); + private ISet GetRightValueToStoreForAddToToMany(TResource leftResource, HasManyAttribute relationship, + ISet rightResourceIdsToAdd) + { + object? rightValueStored = relationship.GetValue(leftResource); - ArgumentGuard.NotNull(secondaryResourceIds, nameof(secondaryResourceIds)); + // @formatter:wrap_chained_method_calls chop_always + // @formatter:wrap_after_property_in_chained_method_calls true - var relationship = (HasManyAttribute)_targetedFields.Relationships.Single(); + HashSet rightResourceIdsStored = CollectionConverter.Instance + .ExtractResources(rightValueStored) + .Select(_dbContext.GetTrackedOrAttach) + .ToHashSet(IdentifiableComparer.Instance); - await _resourceDefinitionAccessor.OnAddToRelationshipAsync(primaryId, relationship, secondaryResourceIds, cancellationToken); + // @formatter:wrap_after_property_in_chained_method_calls restore + // @formatter:wrap_chained_method_calls restore - if (secondaryResourceIds.Any()) - { - using var collector = new PlaceholderResourceCollector(_resourceFactory, _dbContext); - TResource primaryResource = collector.CreateForId(primaryId); + if (rightResourceIdsStored.Count > 0) + { + rightResourceIdsStored.UnionWith(rightResourceIdsToAdd); + return rightResourceIdsStored; + } - await UpdateRelationshipAsync(relationship, primaryResource, secondaryResourceIds, collector, cancellationToken); + return rightResourceIdsToAdd; + } - await _resourceDefinitionAccessor.OnWritingAsync(primaryResource, OperationKind.AddToRelationship, cancellationToken); + /// + public virtual async Task RemoveFromToManyRelationshipAsync(TResource leftResource, ISet rightResourceIds, + CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + leftResource, + rightResourceIds + }); - await SaveChangesAsync(cancellationToken); + ArgumentNullException.ThrowIfNull(leftResource); + ArgumentNullException.ThrowIfNull(rightResourceIds); - await _resourceDefinitionAccessor.OnWriteSucceededAsync(primaryResource, OperationKind.AddToRelationship, cancellationToken); - } - } + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Remove from to-many relationship"); - /// - public virtual async Task RemoveFromToManyRelationshipAsync(TResource primaryResource, ISet secondaryResourceIds, - CancellationToken cancellationToken) + var relationship = (HasManyAttribute)_targetedFields.Relationships.Single(); + HashSet rightResourceIdsToRemove = rightResourceIds.ToHashSet(IdentifiableComparer.Instance); + + await _resourceDefinitionAccessor.OnRemoveFromRelationshipAsync(leftResource, relationship, rightResourceIdsToRemove, cancellationToken); + + if (rightResourceIdsToRemove.Count > 0) { - _traceWriter.LogMethodStart(new - { - primaryResource, - secondaryResourceIds - }); + var leftResourceTracked = (TResource)_dbContext.GetTrackedOrAttach(leftResource); - ArgumentGuard.NotNull(secondaryResourceIds, nameof(secondaryResourceIds)); + // Make Entity Framework Core believe any additional resources added from ResourceDefinition already exist in database. + IIdentifiable[] extraResourceIdsToRemove = rightResourceIdsToRemove.Where(rightId => !rightResourceIds.Contains(rightId)).ToArray(); - var relationship = (HasManyAttribute)_targetedFields.Relationships.Single(); + object? rightValueStored = relationship.GetValue(leftResourceTracked); - await _resourceDefinitionAccessor.OnRemoveFromRelationshipAsync(primaryResource, relationship, secondaryResourceIds, cancellationToken); + // @formatter:wrap_chained_method_calls chop_always + // @formatter:wrap_after_property_in_chained_method_calls true - if (secondaryResourceIds.Any()) - { - object rightValue = relationship.GetValue(primaryResource); + IIdentifiable[] rightResourceIdsStored = CollectionConverter.Instance + .ExtractResources(rightValueStored) + .Concat(extraResourceIdsToRemove) + .Select(_dbContext.GetTrackedOrAttach) + .ToArray(); - HashSet rightResourceIds = _collectionConverter.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); - rightResourceIds.ExceptWith(secondaryResourceIds); + // @formatter:wrap_after_property_in_chained_method_calls restore + // @formatter:wrap_chained_method_calls restore + + rightValueStored = CollectionConverter.Instance.CopyToTypedCollection(rightResourceIdsStored, relationship.Property.PropertyType); + relationship.SetValue(leftResourceTracked, rightValueStored); + + MarkRelationshipAsLoaded(leftResourceTracked, relationship); - AssertIsNotClearingRequiredRelationship(relationship, primaryResource, rightResourceIds); + HashSet rightResourceIdsToStore = rightResourceIdsStored.ToHashSet(IdentifiableComparer.Instance); + rightResourceIdsToStore.ExceptWith(rightResourceIdsToRemove); - using var collector = new PlaceholderResourceCollector(_resourceFactory, _dbContext); - await UpdateRelationshipAsync(relationship, primaryResource, rightResourceIds, collector, cancellationToken); + if (!rightResourceIdsToStore.SetEquals(rightResourceIdsStored)) + { + await UpdateRelationshipAsync(relationship, leftResourceTracked, rightResourceIdsToStore, cancellationToken); - await _resourceDefinitionAccessor.OnWritingAsync(primaryResource, OperationKind.RemoveFromRelationship, cancellationToken); + await _resourceDefinitionAccessor.OnWritingAsync(leftResourceTracked, WriteOperationKind.RemoveFromRelationship, cancellationToken); await SaveChangesAsync(cancellationToken); - await _resourceDefinitionAccessor.OnWriteSucceededAsync(primaryResource, OperationKind.RemoveFromRelationship, cancellationToken); + await _resourceDefinitionAccessor.OnWriteSucceededAsync(leftResourceTracked, WriteOperationKind.RemoveFromRelationship, cancellationToken); } } + } - protected async Task UpdateRelationshipAsync(RelationshipAttribute relationship, TResource leftResource, object valueToAssign, - PlaceholderResourceCollector collector, CancellationToken cancellationToken) - { - object trackedValueToAssign = EnsureRelationshipValueToAssignIsTracked(valueToAssign, relationship.Property.PropertyType, collector); + private void MarkRelationshipAsLoaded(TResource leftResource, RelationshipAttribute relationship) + { + EntityEntry leftEntry = _dbContext.Entry(leftResource); + CollectionEntry rightCollectionEntry = leftEntry.Collection(relationship.Property.Name); + rightCollectionEntry.IsLoaded = true; - if (RequireLoadOfInverseRelationship(relationship, trackedValueToAssign)) - { - EntityEntry entityEntry = _dbContext.Entry(trackedValueToAssign); - string inversePropertyName = relationship.InverseNavigationProperty.Name; + if (rightCollectionEntry.Metadata is ISkipNavigation skipNavigation) + { + MarkManyToManyRelationshipAsLoaded(leftEntry, skipNavigation); + } + } - await entityEntry.Reference(inversePropertyName).LoadAsync(cancellationToken); - } + private void MarkManyToManyRelationshipAsLoaded(EntityEntry leftEntry, ISkipNavigation skipNavigation) + { + string[] primaryKeyNames = skipNavigation.ForeignKey.PrincipalKey.Properties.Select(property => property.Name).ToArray(); + object?[] primaryKeyValues = GetCurrentKeyValues(leftEntry, primaryKeyNames); - relationship.SetValue(leftResource, trackedValueToAssign); - } + string[] foreignKeyNames = skipNavigation.ForeignKey.Properties.Select(property => property.Name).ToArray(); - private object EnsureRelationshipValueToAssignIsTracked(object rightValue, Type relationshipPropertyType, PlaceholderResourceCollector collector) + foreach (EntityEntry joinEntry in _dbContext.ChangeTracker.Entries().Where(entry => entry.Metadata == skipNavigation.JoinEntityType).ToArray()) { - if (rightValue == null) + object?[] foreignKeyValues = GetCurrentKeyValues(joinEntry, foreignKeyNames); + + if (primaryKeyValues.SequenceEqual(foreignKeyValues)) { - return null; + joinEntry.State = EntityState.Unchanged; } + } + } - ICollection rightResources = _collectionConverter.ExtractResources(rightValue); - IIdentifiable[] rightResourcesTracked = rightResources.Select(collector.CaptureExisting).ToArray(); + private static object?[] GetCurrentKeyValues(EntityEntry entry, IEnumerable keyNames) + { + return keyNames.Select(keyName => entry.Property(keyName).CurrentValue).ToArray(); + } - return rightValue is IEnumerable - ? (object)_collectionConverter.CopyToTypedCollection(rightResourcesTracked, relationshipPropertyType) - : rightResourcesTracked.Single(); - } + protected async Task UpdateRelationshipAsync(RelationshipAttribute relationship, TResource leftResource, object? valueToAssign, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(relationship); + ArgumentNullException.ThrowIfNull(leftResource); - private bool RequireLoadOfInverseRelationship(RelationshipAttribute relationship, object trackedValueToAssign) + object? trackedValueToAssign = EnsureRelationshipValueToAssignIsTracked(valueToAssign, relationship.Property.PropertyType); + + if (RequireLoadOfInverseRelationship(relationship, trackedValueToAssign)) { - // See https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/502. - return trackedValueToAssign != null && relationship.InverseNavigationProperty != null && IsOneToOneRelationship(relationship); + EntityEntry entityEntry = _dbContext.Entry(trackedValueToAssign); + string inversePropertyName = relationship.InverseNavigationProperty!.Name; + + await entityEntry.Reference(inversePropertyName).LoadAsync(cancellationToken); } - private bool IsOneToOneRelationship(RelationshipAttribute relationship) - { - if (relationship is HasOneAttribute hasOneRelationship) - { - Type elementType = _collectionConverter.TryGetCollectionElementType(hasOneRelationship.InverseNavigationProperty.PropertyType); - return elementType == null; - } + relationship.SetValue(leftResource, trackedValueToAssign); + } - return false; + private object? EnsureRelationshipValueToAssignIsTracked(object? rightValue, Type relationshipPropertyType) + { + if (rightValue == null) + { + return null; } - protected virtual async Task SaveChangesAsync(CancellationToken cancellationToken) + IReadOnlyCollection rightResources = CollectionConverter.Instance.ExtractResources(rightValue); + IIdentifiable[] rightResourcesTracked = rightResources.Select(_dbContext.GetTrackedOrAttach).ToArray(); + + return rightValue is IEnumerable + ? 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. + if (trackedValueToAssign != null && relationship is HasOneAttribute { IsOneToOne: true }) { - cancellationToken.ThrowIfCancellationRequested(); + IEntityType? leftEntityType = _dbContext.Model.FindEntityType(relationship.LeftType.ClrType); + INavigation? navigation = leftEntityType?.FindNavigation(relationship.Property.Name); - try + if (HasForeignKeyAtLeftSide(relationship, navigation)) { - await _dbContext.SaveChangesAsync(cancellationToken); - } - catch (Exception exception) when (exception is DbUpdateException || exception is InvalidOperationException) - { - if (_dbContext.Database.CurrentTransaction != null) - { - // The ResourceService calling us needs to run additional SQL queries after an aborted transaction, - // to determine error cause. This fails when a failed transaction is still in progress. - await _dbContext.Database.CurrentTransaction.RollbackAsync(cancellationToken); - } - - throw new DataStoreUpdateException(exception); + return true; } } + + return false; } - /// - /// Implements the foundational Repository layer in the JsonApiDotNetCore architecture that uses Entity Framework Core. - /// - [PublicAPI] - public class EntityFrameworkCoreRepository : EntityFrameworkCoreRepository, IResourceRepository - where TResource : class, IIdentifiable + protected virtual async Task SaveChangesAsync(CancellationToken cancellationToken) { - public EntityFrameworkCoreRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory) + cancellationToken.ThrowIfCancellationRequested(); + + try { + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Persist EF Core changes", MeasurementSettings.ExcludeDatabaseInPercentages); + + await _dbContext.SaveChangesAsync(cancellationToken); + } + catch (Exception exception) when (exception is DbUpdateException or InvalidOperationException) + { + _dbContext.ResetChangeTracker(); + + throw new DataStoreUpdateException(exception); } } } diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreSupport.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreSupport.cs deleted file mode 100644 index 042c4613ce..0000000000 --- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreSupport.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore; - -#pragma warning disable AV1008 // Class should not be static - -namespace JsonApiDotNetCore.Repositories -{ - internal static class EntityFrameworkCoreSupport - { - public static Version Version { get; } = typeof(DbContext).Assembly.GetName().Version; - } -} diff --git a/src/JsonApiDotNetCore/Repositories/IDbContextResolver.cs b/src/JsonApiDotNetCore/Repositories/IDbContextResolver.cs index 0a38f2dfcc..f137e8f453 100644 --- a/src/JsonApiDotNetCore/Repositories/IDbContextResolver.cs +++ b/src/JsonApiDotNetCore/Repositories/IDbContextResolver.cs @@ -1,12 +1,11 @@ using Microsoft.EntityFrameworkCore; -namespace JsonApiDotNetCore.Repositories +namespace JsonApiDotNetCore.Repositories; + +/// +/// Provides a method to resolve a . +/// +public interface IDbContextResolver { - /// - /// Provides a method to resolve a . - /// - public interface IDbContextResolver - { - DbContext GetContext(); - } + DbContext GetContext(); } diff --git a/src/JsonApiDotNetCore/Repositories/IRepositorySupportsTransaction.cs b/src/JsonApiDotNetCore/Repositories/IRepositorySupportsTransaction.cs index 4a59e98a75..aac80d11df 100644 --- a/src/JsonApiDotNetCore/Repositories/IRepositorySupportsTransaction.cs +++ b/src/JsonApiDotNetCore/Repositories/IRepositorySupportsTransaction.cs @@ -1,16 +1,15 @@ using JetBrains.Annotations; -namespace JsonApiDotNetCore.Repositories +namespace JsonApiDotNetCore.Repositories; + +/// +/// Used to indicate that an supports execution inside a transaction. +/// +[PublicAPI] +public interface IRepositorySupportsTransaction { /// - /// Used to indicate that an supports execution inside a transaction. + /// Identifies the currently active transaction. /// - [PublicAPI] - public interface IRepositorySupportsTransaction - { - /// - /// Identifies the currently active transaction. - /// - string TransactionId { get; } - } + string? TransactionId { get; } } diff --git a/src/JsonApiDotNetCore/Repositories/IResourceReadRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceReadRepository.cs index a75d95c213..1be60d3825 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceReadRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceReadRepository.cs @@ -1,40 +1,30 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Repositories -{ - /// - public interface IResourceReadRepository : IResourceReadRepository - where TResource : class, IIdentifiable - { - } +namespace JsonApiDotNetCore.Repositories; +/// +/// Groups read operations. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +[PublicAPI] +public interface IResourceReadRepository + where TResource : class, IIdentifiable +{ /// - /// Groups read operations. + /// Executes a read query using the specified constraints and returns the collection of matching resources. /// - /// - /// The resource type. - /// - /// - /// The resource identifier type. - /// - [PublicAPI] - public interface IResourceReadRepository - where TResource : class, IIdentifiable - { - /// - /// Executes a read query using the specified constraints and returns the collection of matching resources. - /// - Task> GetAsync(QueryLayer layer, CancellationToken cancellationToken); + Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken); - /// - /// Executes a read query using the specified top-level filter and returns the top-level count of matching resources. - /// - Task CountAsync(FilterExpression topFilter, CancellationToken cancellationToken); - } + /// + /// Executes a read query using the specified filter and returns the count of matching resources. + /// + Task CountAsync(FilterExpression? filter, CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs index d43e355f06..218a09cb93 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceRepository.cs @@ -1,32 +1,17 @@ using JetBrains.Annotations; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Repositories -{ - /// - /// Represents the foundational Resource Repository layer in the JsonApiDotNetCore architecture that provides data access to an underlying store. - /// - /// - /// The resource type. - /// - [PublicAPI] - public interface IResourceRepository - : IResourceRepository, IResourceReadRepository, IResourceWriteRepository - where TResource : class, IIdentifiable - { - } +namespace JsonApiDotNetCore.Repositories; - /// - /// Represents the foundational Resource Repository layer in the JsonApiDotNetCore architecture that provides data access to an underlying store. - /// - /// - /// The resource type. - /// - /// - /// The resource identifier type. - /// - public interface IResourceRepository : IResourceReadRepository, IResourceWriteRepository - where TResource : class, IIdentifiable - { - } -} +/// +/// Represents the foundational Resource Repository layer in the JsonApiDotNetCore architecture that provides data access to an underlying store. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +[PublicAPI] +public interface IResourceRepository : IResourceReadRepository, IResourceWriteRepository + where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs index 607512c242..df08f04f26 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceRepositoryAccessor.cs @@ -1,82 +1,83 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; +using System.Diagnostics.CodeAnalysis; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Repositories +namespace JsonApiDotNetCore.Repositories; + +/// +/// Retrieves an instance from the D/I container and invokes a method on it. +/// +public interface IResourceRepositoryAccessor { /// - /// Retrieves an instance from the D/I container and invokes a method on it. + /// Uses the to lookup the corresponding for the specified CLR type. + /// + ResourceType LookupResourceType(Type resourceClrType); + + /// + /// Invokes for the specified resource type. /// - public interface IResourceRepositoryAccessor - { - /// - /// Invokes . - /// - Task> GetAsync(QueryLayer layer, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; + Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; - /// - /// Invokes for the specified resource type. - /// - Task> GetAsync(Type resourceType, QueryLayer layer, CancellationToken cancellationToken); + /// + /// Invokes for the specified resource type. + /// + Task> GetAsync(ResourceType resourceType, QueryLayer queryLayer, CancellationToken cancellationToken); - /// - /// Invokes for the specified resource type. - /// - Task CountAsync(FilterExpression topFilter, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; + /// + /// Invokes for the specified resource type. + /// + Task CountAsync(ResourceType resourceType, FilterExpression? filter, CancellationToken cancellationToken); - /// - /// Invokes . - /// - Task GetForCreateAsync(TId id, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; + /// + /// Invokes for the specified resource type. + /// + Task GetForCreateAsync(Type resourceClrType, [DisallowNull] TId id, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; - /// - /// Invokes . - /// - Task CreateAsync(TResource resourceFromRequest, TResource resourceForDatabase, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; + /// + /// Invokes for the specified resource type. + /// + Task CreateAsync(TResource resourceFromRequest, TResource resourceForDatabase, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; - /// - /// Invokes . - /// - Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; + /// + /// Invokes for the specified resource type. + /// + Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; - /// - /// Invokes . - /// - Task UpdateAsync(TResource resourceFromRequest, TResource resourceFromDatabase, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; + /// + /// Invokes for the specified resource type. + /// + Task UpdateAsync(TResource resourceFromRequest, TResource resourceFromDatabase, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; - /// - /// Invokes for the specified resource type. - /// - Task DeleteAsync(TId id, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; + /// + /// Invokes for the specified resource type. + /// + Task DeleteAsync(TResource? resourceFromDatabase, [DisallowNull] TId id, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; - /// - /// Invokes . - /// - Task SetRelationshipAsync(TResource primaryResource, object secondaryResourceIds, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; + /// + /// Invokes for the specified resource type. + /// + Task SetRelationshipAsync(TResource leftResource, object? rightValue, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; - /// - /// Invokes for the specified resource type. - /// - Task AddToToManyRelationshipAsync(TId primaryId, ISet secondaryResourceIds, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; + /// + /// Invokes for the specified resource type. + /// + Task AddToToManyRelationshipAsync(TResource? leftResource, [DisallowNull] TId leftId, ISet rightResourceIds, + CancellationToken cancellationToken) + where TResource : class, IIdentifiable; - /// - /// Invokes . - /// - Task RemoveFromToManyRelationshipAsync(TResource primaryResource, ISet secondaryResourceIds, - CancellationToken cancellationToken) - where TResource : class, IIdentifiable; - } + /// + /// Invokes for the specified resource type. + /// + Task RemoveFromToManyRelationshipAsync(TResource leftResource, ISet rightResourceIds, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; } diff --git a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs index 049e02ce8c..49d2c60d73 100644 --- a/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs +++ b/src/JsonApiDotNetCore/Repositories/IResourceWriteRepository.cs @@ -1,72 +1,64 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; +using System.Diagnostics.CodeAnalysis; using JetBrains.Annotations; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Repositories -{ - /// - public interface IResourceWriteRepository : IResourceWriteRepository - where TResource : class, IIdentifiable - { - } +namespace JsonApiDotNetCore.Repositories; +/// +/// Groups write operations. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +[PublicAPI] +public interface IResourceWriteRepository + where TResource : class, IIdentifiable +{ /// - /// Groups write operations. + /// Creates a new resource instance, in preparation for . /// - /// - /// The resource type. - /// - /// - /// The resource identifier type. - /// - [PublicAPI] - public interface IResourceWriteRepository - where TResource : class, IIdentifiable - { - /// - /// Creates a new resource instance, in preparation for . - /// - /// - /// This method can be overridden to assign resource-specific required relationships. - /// - Task GetForCreateAsync(TId id, CancellationToken cancellationToken); + /// + /// This method can be overridden to assign resource-specific required relationships. + /// + Task GetForCreateAsync(Type resourceClrType, [DisallowNull] TId id, CancellationToken cancellationToken); - /// - /// Creates a new resource in the underlying data store. - /// - Task CreateAsync(TResource resourceFromRequest, TResource resourceForDatabase, CancellationToken cancellationToken); + /// + /// Creates a new resource in the underlying data store. + /// + Task CreateAsync(TResource resourceFromRequest, TResource resourceForDatabase, CancellationToken cancellationToken); - /// - /// Retrieves a resource with all of its attributes, including the set of targeted relationships, in preparation for . - /// - Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken); + /// + /// Retrieves a resource with all of its attributes, including the set of targeted relationships, in preparation for . + /// + Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken); - /// - /// Updates the attributes and relationships of an existing resource in the underlying data store. - /// - Task UpdateAsync(TResource resourceFromRequest, TResource resourceFromDatabase, CancellationToken cancellationToken); + /// + /// Updates the attributes and relationships of an existing resource in the underlying data store. + /// + Task UpdateAsync(TResource resourceFromRequest, TResource resourceFromDatabase, CancellationToken cancellationToken); - /// - /// Deletes an existing resource from the underlying data store. - /// - Task DeleteAsync(TId id, CancellationToken cancellationToken); + /// + /// Deletes an existing resource from the underlying data store. + /// + Task DeleteAsync(TResource? resourceFromDatabase, [DisallowNull] TId id, CancellationToken cancellationToken); - /// - /// Performs a complete replacement of the relationship in the underlying data store. - /// - Task SetRelationshipAsync(TResource primaryResource, object secondaryResourceIds, CancellationToken cancellationToken); + /// + /// Performs a complete replacement of the relationship in the underlying data store. + /// + Task SetRelationshipAsync(TResource leftResource, object? rightValue, CancellationToken cancellationToken); - /// - /// Adds resources to a to-many relationship in the underlying data store. - /// - Task AddToToManyRelationshipAsync(TId primaryId, ISet secondaryResourceIds, CancellationToken cancellationToken); + /// + /// Adds resources to a to-many relationship in the underlying data store. + /// + Task AddToToManyRelationshipAsync(TResource? leftResource, [DisallowNull] TId leftId, ISet rightResourceIds, + CancellationToken cancellationToken); - /// - /// Removes resources from a to-many relationship in the underlying data store. - /// - Task RemoveFromToManyRelationshipAsync(TResource primaryResource, ISet secondaryResourceIds, CancellationToken cancellationToken); - } + /// + /// Removes resources from a to-many relationship in the underlying data store. + /// + Task RemoveFromToManyRelationshipAsync(TResource leftResource, ISet rightResourceIds, CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/Repositories/MemoryLeakDetectionBugRewriter.cs b/src/JsonApiDotNetCore/Repositories/MemoryLeakDetectionBugRewriter.cs deleted file mode 100644 index 557f330791..0000000000 --- a/src/JsonApiDotNetCore/Repositories/MemoryLeakDetectionBugRewriter.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Repositories -{ - /// - /// Removes projections from a when its resource type uses injected parameters, as a workaround for EF Core bug - /// https://github.com/dotnet/efcore/issues/20502, which exists in versions below v5. - /// - /// - /// Note that by using this workaround, nested filtering, paging and sorting all remain broken in EF Core 3.1 when using injected parameters in - /// resources. But at least it enables simple top-level queries to succeed without an exception. - /// - [PublicAPI] - public sealed class MemoryLeakDetectionBugRewriter - { - public QueryLayer Rewrite(QueryLayer queryLayer) - { - ArgumentGuard.NotNull(queryLayer, nameof(queryLayer)); - - return RewriteLayer(queryLayer); - } - - private QueryLayer RewriteLayer(QueryLayer queryLayer) - { - if (queryLayer != null) - { - queryLayer.Projection = RewriteProjection(queryLayer.Projection, queryLayer.ResourceContext); - } - - return queryLayer; - } - - private IDictionary RewriteProjection(IDictionary projection, - ResourceContext resourceContext) - { - if (projection.IsNullOrEmpty()) - { - return projection; - } - - var newProjection = new Dictionary(); - - foreach ((ResourceFieldAttribute field, QueryLayer layer) in projection) - { - QueryLayer newLayer = RewriteLayer(layer); - newProjection.Add(field, newLayer); - } - - if (!ResourceFactory.HasSingleConstructorWithoutParameters(resourceContext.ResourceType)) - { - return null; - } - - return newProjection; - } - } -} diff --git a/src/JsonApiDotNetCore/Repositories/PlaceholderResourceCollector.cs b/src/JsonApiDotNetCore/Repositories/PlaceholderResourceCollector.cs deleted file mode 100644 index 51efa96aae..0000000000 --- a/src/JsonApiDotNetCore/Repositories/PlaceholderResourceCollector.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using Microsoft.EntityFrameworkCore; - -namespace JsonApiDotNetCore.Repositories -{ - /// - /// Creates placeholder resource instances (with only their ID property set), which are added to the Entity Framework Core change tracker so they can be - /// used in relationship updates without fetching the resource. On disposal, the created placeholders are detached, leaving the change tracker in a clean - /// state for reuse. - /// - [PublicAPI] - public sealed class PlaceholderResourceCollector : IDisposable - { - private readonly IResourceFactory _resourceFactory; - private readonly DbContext _dbContext; - private readonly List _resources = new List(); - - public PlaceholderResourceCollector(IResourceFactory resourceFactory, DbContext dbContext) - { - ArgumentGuard.NotNull(resourceFactory, nameof(resourceFactory)); - ArgumentGuard.NotNull(dbContext, nameof(dbContext)); - - _resourceFactory = resourceFactory; - _dbContext = dbContext; - } - - /// - /// Creates a new placeholder resource, assigns the specified ID, adds it to the change tracker in state and - /// registers it for detachment. - /// - public TResource CreateForId(TId id) - where TResource : IIdentifiable - { - var placeholderResource = _resourceFactory.CreateInstance(); - placeholderResource.Id = id; - - return CaptureExisting(placeholderResource); - } - - /// - /// Takes an existing placeholder resource, adds it to the change tracker in state and registers it for detachment. - /// - public TResource CaptureExisting(TResource placeholderResource) - where TResource : IIdentifiable - { - var resourceTracked = (TResource)_dbContext.GetTrackedOrAttach(placeholderResource); - - if (ReferenceEquals(resourceTracked, placeholderResource)) - { - _resources.Add(resourceTracked); - } - - return resourceTracked; - } - - /// - /// Detaches the collected placeholder resources from the change tracker. - /// - public void Dispose() - { - Detach(_resources); - - _resources.Clear(); - } - - private void Detach(IEnumerable resources) - { - foreach (object resource in resources) - { - try - { - _dbContext.Entry(resource).State = EntityState.Detached; - } - catch (InvalidOperationException) - { - // If SaveChanges() threw due to a foreign key constraint violation, its exception is rethrown here. - // We swallow this exception, to allow the originating error to propagate. - } - } - } - } -} diff --git a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs index 25de9a3409..4e3b5163a3 100644 --- a/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs +++ b/src/JsonApiDotNetCore/Repositories/ResourceRepositoryAccessor.cs @@ -1,7 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; +using System.Diagnostics.CodeAnalysis; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Errors; @@ -11,175 +8,191 @@ using JsonApiDotNetCore.Resources; using Microsoft.Extensions.DependencyInjection; -namespace JsonApiDotNetCore.Repositories +namespace JsonApiDotNetCore.Repositories; + +/// +[PublicAPI] +public class ResourceRepositoryAccessor : IResourceRepositoryAccessor { + private readonly IServiceProvider _serviceProvider; + private readonly IResourceGraph _resourceGraph; + private readonly IJsonApiRequest _request; + + public ResourceRepositoryAccessor(IServiceProvider serviceProvider, IResourceGraph resourceGraph, IJsonApiRequest request) + { + ArgumentNullException.ThrowIfNull(serviceProvider); + ArgumentNullException.ThrowIfNull(resourceGraph); + ArgumentNullException.ThrowIfNull(request); + + _serviceProvider = serviceProvider; + _resourceGraph = resourceGraph; + _request = request; + } + /// - [PublicAPI] - public class ResourceRepositoryAccessor : IResourceRepositoryAccessor + public ResourceType LookupResourceType(Type resourceClrType) { - private readonly IServiceProvider _serviceProvider; - private readonly IResourceContextProvider _resourceContextProvider; - private readonly IJsonApiRequest _request; + ArgumentNullException.ThrowIfNull(resourceClrType); - public ResourceRepositoryAccessor(IServiceProvider serviceProvider, IResourceContextProvider resourceContextProvider, IJsonApiRequest request) - { - ArgumentGuard.NotNull(serviceProvider, nameof(serviceProvider)); - ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider)); - ArgumentGuard.NotNull(request, nameof(request)); + return _resourceGraph.GetResourceType(resourceClrType); + } - _serviceProvider = serviceProvider; - _resourceContextProvider = resourceContextProvider; - _request = request; - } + /// + public async Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentNullException.ThrowIfNull(queryLayer); - /// - public async Task> GetAsync(QueryLayer layer, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - dynamic repository = ResolveReadRepository(typeof(TResource)); - return (IReadOnlyCollection)await repository.GetAsync(layer, cancellationToken); - } + dynamic repository = ResolveReadRepository(typeof(TResource)); + return (IReadOnlyCollection)await repository.GetAsync(queryLayer, cancellationToken); + } - /// - public async Task> GetAsync(Type resourceType, QueryLayer layer, CancellationToken cancellationToken) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + /// + public async Task> GetAsync(ResourceType resourceType, QueryLayer queryLayer, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(queryLayer); - dynamic repository = ResolveReadRepository(resourceType); - return (IReadOnlyCollection)await repository.GetAsync(layer, cancellationToken); - } + dynamic repository = ResolveReadRepository(resourceType); + return (IReadOnlyCollection)await repository.GetAsync(queryLayer, cancellationToken); + } - /// - public async Task CountAsync(FilterExpression topFilter, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - dynamic repository = ResolveReadRepository(typeof(TResource)); - return (int)await repository.CountAsync(topFilter, cancellationToken); - } + /// + public async Task CountAsync(ResourceType resourceType, FilterExpression? filter, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resourceType); - /// - public async Task GetForCreateAsync(TId id, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - dynamic repository = GetWriteRepository(typeof(TResource)); - return await repository.GetForCreateAsync(id, cancellationToken); - } + dynamic repository = ResolveReadRepository(resourceType); + return (int)await repository.CountAsync(filter, cancellationToken); + } - /// - public async Task CreateAsync(TResource resourceFromRequest, TResource resourceForDatabase, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - dynamic repository = GetWriteRepository(typeof(TResource)); - await repository.CreateAsync(resourceFromRequest, resourceForDatabase, cancellationToken); - } + /// + public async Task GetForCreateAsync(Type resourceClrType, [DisallowNull] TId id, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentNullException.ThrowIfNull(resourceClrType); - /// - public async Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - dynamic repository = GetWriteRepository(typeof(TResource)); - return await repository.GetForUpdateAsync(queryLayer, cancellationToken); - } + dynamic repository = GetWriteRepository(typeof(TResource)); + return await repository.GetForCreateAsync(resourceClrType, id, cancellationToken); + } - /// - public async Task UpdateAsync(TResource resourceFromRequest, TResource resourceFromDatabase, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - dynamic repository = GetWriteRepository(typeof(TResource)); - await repository.UpdateAsync(resourceFromRequest, resourceFromDatabase, cancellationToken); - } + /// + public async Task CreateAsync(TResource resourceFromRequest, TResource resourceForDatabase, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentNullException.ThrowIfNull(resourceFromRequest); + ArgumentNullException.ThrowIfNull(resourceForDatabase); - /// - public async Task DeleteAsync(TId id, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - dynamic repository = GetWriteRepository(typeof(TResource)); - await repository.DeleteAsync(id, cancellationToken); - } + dynamic repository = GetWriteRepository(typeof(TResource)); + await repository.CreateAsync(resourceFromRequest, resourceForDatabase, cancellationToken); + } - /// - public async Task SetRelationshipAsync(TResource primaryResource, object secondaryResourceIds, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - dynamic repository = GetWriteRepository(typeof(TResource)); - await repository.SetRelationshipAsync(primaryResource, secondaryResourceIds, cancellationToken); - } + /// + public async Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentNullException.ThrowIfNull(queryLayer); - /// - public async Task AddToToManyRelationshipAsync(TId primaryId, ISet secondaryResourceIds, - CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - dynamic repository = GetWriteRepository(typeof(TResource)); - await repository.AddToToManyRelationshipAsync(primaryId, secondaryResourceIds, cancellationToken); - } + dynamic repository = GetWriteRepository(typeof(TResource)); + return await repository.GetForUpdateAsync(queryLayer, cancellationToken); + } - /// - public async Task RemoveFromToManyRelationshipAsync(TResource primaryResource, ISet secondaryResourceIds, - CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - dynamic repository = GetWriteRepository(typeof(TResource)); - await repository.RemoveFromToManyRelationshipAsync(primaryResource, secondaryResourceIds, cancellationToken); - } + /// + public async Task UpdateAsync(TResource resourceFromRequest, TResource resourceFromDatabase, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentNullException.ThrowIfNull(resourceFromRequest); + ArgumentNullException.ThrowIfNull(resourceFromDatabase); - protected virtual object ResolveReadRepository(Type resourceType) - { - ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(resourceType); + dynamic repository = GetWriteRepository(typeof(TResource)); + await repository.UpdateAsync(resourceFromRequest, resourceFromDatabase, cancellationToken); + } - if (resourceContext.IdentityType == typeof(int)) - { - Type intRepositoryType = typeof(IResourceReadRepository<>).MakeGenericType(resourceContext.ResourceType); - object intRepository = _serviceProvider.GetService(intRepositoryType); + /// + public async Task DeleteAsync(TResource? resourceFromDatabase, [DisallowNull] TId id, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + dynamic repository = GetWriteRepository(typeof(TResource)); + await repository.DeleteAsync(resourceFromDatabase, id, cancellationToken); + } - if (intRepository != null) - { - return intRepository; - } - } + /// + public async Task SetRelationshipAsync(TResource leftResource, object? rightValue, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentNullException.ThrowIfNull(leftResource); - Type resourceDefinitionType = typeof(IResourceReadRepository<,>).MakeGenericType(resourceContext.ResourceType, resourceContext.IdentityType); - return _serviceProvider.GetRequiredService(resourceDefinitionType); - } + dynamic repository = GetWriteRepository(typeof(TResource)); + await repository.SetRelationshipAsync(leftResource, rightValue, cancellationToken); + } - private object GetWriteRepository(Type resourceType) - { - object writeRepository = ResolveWriteRepository(resourceType); + /// + 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); + } - if (_request.TransactionId != null) + /// + public async Task RemoveFromToManyRelationshipAsync(TResource leftResource, ISet rightResourceIds, + 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) + { + ArgumentNullException.ThrowIfNull(resourceType); + + Type repositoryType = typeof(IResourceReadRepository<,>).MakeGenericType(resourceType.ClrType, resourceType.IdentityClrType); + return _serviceProvider.GetRequiredService(repositoryType); + } + + private object GetWriteRepository(Type resourceClrType) + { + object writeRepository = ResolveWriteRepository(resourceClrType); + + if (_request.TransactionId != null) + { + if (writeRepository is not IRepositorySupportsTransaction repository) { - if (!(writeRepository is IRepositorySupportsTransaction repository)) - { - ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(resourceType); - throw new MissingTransactionSupportException(resourceContext.PublicName); - } - - if (repository.TransactionId != _request.TransactionId) - { - throw new NonParticipatingTransactionException(); - } + ResourceType resourceType = _resourceGraph.GetResourceType(resourceClrType); + throw new MissingTransactionSupportException(resourceType.PublicName); } - return writeRepository; + if (repository.TransactionId != _request.TransactionId) + { + throw new NonParticipatingTransactionException(); + } } - protected virtual object ResolveWriteRepository(Type resourceType) - { - ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(resourceType); + return writeRepository; + } - if (resourceContext.IdentityType == typeof(int)) - { - Type intRepositoryType = typeof(IResourceWriteRepository<>).MakeGenericType(resourceContext.ResourceType); - object intRepository = _serviceProvider.GetService(intRepositoryType); + protected virtual object ResolveWriteRepository(Type resourceClrType) + { + ArgumentNullException.ThrowIfNull(resourceClrType); - if (intRepository != null) - { - return intRepository; - } - } + ResourceType resourceType = _resourceGraph.GetResourceType(resourceClrType); - Type resourceDefinitionType = typeof(IResourceWriteRepository<,>).MakeGenericType(resourceContext.ResourceType, resourceContext.IdentityType); - return _serviceProvider.GetRequiredService(resourceDefinitionType); - } + Type resourceDefinitionType = typeof(IResourceWriteRepository<,>).MakeGenericType(resourceType.ClrType, resourceType.IdentityClrType); + return _serviceProvider.GetRequiredService(resourceDefinitionType); } } diff --git a/src/JsonApiDotNetCore/Resources/AbstractResourceWrapper.cs b/src/JsonApiDotNetCore/Resources/AbstractResourceWrapper.cs new file mode 100644 index 0000000000..3cf6233cf2 --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/AbstractResourceWrapper.cs @@ -0,0 +1,15 @@ +namespace JsonApiDotNetCore.Resources; + +/// +internal sealed class AbstractResourceWrapper : Identifiable, IAbstractResourceWrapper +{ + /// + public Type AbstractType { get; } + + public AbstractResourceWrapper(Type abstractType) + { + ArgumentNullException.ThrowIfNull(abstractType); + + AbstractType = abstractType; + } +} diff --git a/src/JsonApiDotNetCore/Resources/Annotations/AttrAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/AttrAttribute.cs deleted file mode 100644 index 23461fc3dd..0000000000 --- a/src/JsonApiDotNetCore/Resources/Annotations/AttrAttribute.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System; -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Resources.Annotations -{ - /// - /// Used to expose a property on a resource class as a JSON:API attribute (https://jsonapi.org/format/#document-resource-object-attributes). - /// - [PublicAPI] - [AttributeUsage(AttributeTargets.Property)] - public sealed class AttrAttribute : ResourceFieldAttribute - { - private AttrCapabilities? _capabilities; - - 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. - /// - /// - /// - /// public class Author : Identifiable - /// { - /// [Attr(Capabilities = AttrCapabilities.AllowFilter | AttrCapabilities.AllowSort)] - /// public string Name { get; set; } - /// } - /// - /// - public AttrCapabilities Capabilities - { - get => _capabilities ?? default; - set => _capabilities = value; - } - - /// - /// Get the value of the attribute for the given object. Throws if the attribute does not belong to the provided object. - /// - public object GetValue(object resource) - { - ArgumentGuard.NotNull(resource, nameof(resource)); - - if (Property.GetMethod == null) - { - throw new InvalidOperationException($"Property '{Property.DeclaringType?.Name}.{Property.Name}' is write-only."); - } - - return Property.GetValue(resource); - } - - /// - /// Sets the value of the attribute on the given object. - /// - public void SetValue(object resource, object newValue) - { - ArgumentGuard.NotNull(resource, nameof(resource)); - - if (Property.SetMethod == null) - { - throw new InvalidOperationException($"Property '{Property.DeclaringType?.Name}.{Property.Name}' is read-only."); - } - - Property.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 = (AttrAttribute)obj; - - return Capabilities == other.Capabilities && base.Equals(other); - } - - public override int GetHashCode() - { - return HashCode.Combine(Capabilities, base.GetHashCode()); - } - } -} diff --git a/src/JsonApiDotNetCore/Resources/Annotations/AttrCapabilities.cs b/src/JsonApiDotNetCore/Resources/Annotations/AttrCapabilities.cs deleted file mode 100644 index 9eb93c9377..0000000000 --- a/src/JsonApiDotNetCore/Resources/Annotations/AttrCapabilities.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; - -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/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/Annotations/EagerLoadAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/EagerLoadAttribute.cs deleted file mode 100644 index 623e7eeeb2..0000000000 --- a/src/JsonApiDotNetCore/Resources/Annotations/EagerLoadAttribute.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Reflection; -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Resources.Annotations -{ - /// - /// Used to unconditionally load a related entity that is not exposed as a JSON:API relationship. - /// - /// - /// This is intended for calculated properties that are exposed as JSON:API attributes, which depend on a related entity to always be loaded. - /// Name.First + " " + Name.Last; - /// - /// [EagerLoad] - /// public Name Name { get; set; } - /// } - /// - /// public class Name // not exposed as resource, only database table - /// { - /// public string First { get; set; } - /// public string Last { get; set; } - /// } - /// - /// public class Blog : Identifiable - /// { - /// [HasOne] - /// public User Author { get; set; } - /// } - /// ]]> - /// - [PublicAPI] - [AttributeUsage(AttributeTargets.Property)] - public sealed class EagerLoadAttribute : Attribute - { - public PropertyInfo Property { get; internal set; } - - public IReadOnlyCollection Children { get; internal set; } - } -} diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs deleted file mode 100644 index 3a8b3bc16a..0000000000 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasManyAttribute.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; - -namespace JsonApiDotNetCore.Resources.Annotations -{ - /// - /// Used to expose a property on a resource class as a JSON:API to-many relationship - /// (https://jsonapi.org/format/#document-resource-object-relationships). - /// - /// - /// Articles { get; set; } - /// } - /// ]]> - /// - [AttributeUsage(AttributeTargets.Property)] - public class HasManyAttribute : RelationshipAttribute - { - } -} diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs deleted file mode 100644 index cfe180c761..0000000000 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasManyThroughAttribute.cs +++ /dev/null @@ -1,206 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using JetBrains.Annotations; - -// ReSharper disable NonReadonlyMemberInGetHashCode - -namespace JsonApiDotNetCore.Resources.Annotations -{ - /// - /// Used to expose a property on a resource class as a JSON:API to-many relationship (https://jsonapi.org/format/#document-resource-object-relationships) - /// through a many-to-many join relationship. - /// - /// - /// In the following example, we expose a relationship named "tags" through the navigation property `ArticleTags`. The `Tags` property is decorated with - /// `NotMapped` so that EF does not try to map this to a database relationship. - /// Tags { get; set; } - /// public ISet ArticleTags { get; set; } - /// } - /// - /// public class Tag : Identifiable - /// { - /// [Attr] - /// public string Name { get; set; } - /// } - /// - /// public sealed class ArticleTag - /// { - /// public int ArticleId { get; set; } - /// public Article Article { get; set; } - /// - /// public int TagId { get; set; } - /// public Tag Tag { get; set; } - /// } - /// ]]> - /// - [PublicAPI] - [AttributeUsage(AttributeTargets.Property)] - public sealed class HasManyThroughAttribute : HasManyAttribute - { - private static readonly CollectionConverter CollectionConverter = new CollectionConverter(); - - /// - /// The name of the join property on the parent resource. In the example described above, this would be "ArticleTags". - /// - public string ThroughPropertyName { get; } - - /// - /// The join type. In the example described above, this would be `ArticleTag`. - /// - public Type ThroughType { get; internal set; } - - /// - /// The navigation property back to the parent resource from the through type. In the example described above, this would point to the - /// `Article.ArticleTags.Article` property. - /// - public PropertyInfo LeftProperty { get; internal set; } - - /// - /// The ID property back to the parent resource from the through type. In the example described above, this would point to the - /// `Article.ArticleTags.ArticleId` property. - /// - public PropertyInfo LeftIdProperty { get; internal set; } - - /// - /// The navigation property to the related resource from the through type. In the example described above, this would point to the - /// `Article.ArticleTags.Tag` property. - /// - public PropertyInfo RightProperty { get; internal set; } - - /// - /// The ID property to the related resource from the through type. In the example described above, this would point to the `Article.ArticleTags.TagId` - /// property. - /// - public PropertyInfo RightIdProperty { get; internal set; } - - /// - /// The join resource property on the parent resource. In the example described above, this would point to the `Article.ArticleTags` property. - /// - public PropertyInfo ThroughProperty { get; internal set; } - - /// - /// The internal navigation property path to the related resource. In the example described above, this would contain "ArticleTags.Tag". - /// - public override string RelationshipPath => $"{ThroughProperty.Name}.{RightProperty.Name}"; - - /// - /// Required for a self-referencing many-to-many relationship. Contains the name of the property back to the parent resource from the through type. - /// - public string LeftPropertyName { get; set; } - - /// - /// Required for a self-referencing many-to-many relationship. Contains the name of the property to the related resource from the through type. - /// - public string RightPropertyName { get; set; } - - /// - /// Optional. Can be used to indicate a non-default name for the ID property back to the parent resource from the through type. Defaults to the name of - /// suffixed with "Id". In the example described above, this would be "ArticleId". - /// - public string LeftIdPropertyName { get; set; } - - /// - /// Optional. Can be used to indicate a non-default name for the ID property to the related resource from the through type. Defaults to the name of - /// suffixed with "Id". In the example described above, this would be "TagId". - /// - public string RightIdPropertyName { get; set; } - - /// - /// Creates a HasMany relationship through a many-to-many join relationship. - /// - /// - /// The name of the navigation property that will be used to access the join relationship. - /// - public HasManyThroughAttribute(string throughPropertyName) - { - ArgumentGuard.NotNullNorEmpty(throughPropertyName, nameof(throughPropertyName)); - - ThroughPropertyName = throughPropertyName; - } - - /// - /// Traverses through the provided resource and returns the value of the relationship on the other side of the through type. In the example described - /// above, this would be the value of "Articles.ArticleTags.Tag". - /// - public override object GetValue(object resource) - { - ArgumentGuard.NotNull(resource, nameof(resource)); - - object throughEntity = ThroughProperty.GetValue(resource); - - if (throughEntity == null) - { - return null; - } - - IEnumerable rightResources = ((IEnumerable)throughEntity).Cast().Select(rightResource => RightProperty.GetValue(rightResource)); - - return CollectionConverter.CopyToTypedCollection(rightResources, Property.PropertyType); - } - - /// - /// Traverses through the provided resource and sets the value of the relationship on the other side of the through type. In the example described above, - /// this would be the value of "Articles.ArticleTags.Tag". - /// - public override void SetValue(object resource, object newValue) - { - ArgumentGuard.NotNull(resource, nameof(resource)); - - base.SetValue(resource, newValue); - - if (newValue == null) - { - ThroughProperty.SetValue(resource, null); - } - else - { - var throughResources = new List(); - - foreach (IIdentifiable rightResource in (IEnumerable)newValue) - { - object throughEntity = Activator.CreateInstance(ThroughType); - - LeftProperty.SetValue(throughEntity, resource); - RightProperty.SetValue(throughEntity, rightResource); - throughResources.Add(throughEntity); - } - - IEnumerable typedCollection = CollectionConverter.CopyToTypedCollection(throughResources, ThroughProperty.PropertyType); - ThroughProperty.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 = (HasManyThroughAttribute)obj; - - return ThroughPropertyName == other.ThroughPropertyName && ThroughType == other.ThroughType && LeftProperty == other.LeftProperty && - LeftIdProperty == other.LeftIdProperty && RightProperty == other.RightProperty && RightIdProperty == other.RightIdProperty && - ThroughProperty == other.ThroughProperty && base.Equals(other); - } - - public override int GetHashCode() - { - return HashCode.Combine(ThroughPropertyName, ThroughType, LeftProperty, LeftIdProperty, RightProperty, RightIdProperty, ThroughProperty, - base.GetHashCode()); - } - } -} diff --git a/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs deleted file mode 100644 index 020bd2b5b2..0000000000 --- a/src/JsonApiDotNetCore/Resources/Annotations/HasOneAttribute.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System; - -namespace JsonApiDotNetCore.Resources.Annotations -{ - /// - /// Used to expose a property on a resource class as a JSON:API to-one relationship (https://jsonapi.org/format/#document-resource-object-relationships). - /// - [AttributeUsage(AttributeTargets.Property)] - public sealed class HasOneAttribute : RelationshipAttribute - { - } -} diff --git a/src/JsonApiDotNetCore/Resources/Annotations/LinkTypes.cs b/src/JsonApiDotNetCore/Resources/Annotations/LinkTypes.cs deleted file mode 100644 index d655a8b882..0000000000 --- a/src/JsonApiDotNetCore/Resources/Annotations/LinkTypes.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; - -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/Resources/Annotations/RelationshipAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs deleted file mode 100644 index bb46646faa..0000000000 --- a/src/JsonApiDotNetCore/Resources/Annotations/RelationshipAttribute.cs +++ /dev/null @@ -1,118 +0,0 @@ -using System; -using System.Reflection; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; - -// ReSharper disable NonReadonlyMemberInGetHashCode - -namespace JsonApiDotNetCore.Resources.Annotations -{ - /// - /// Used to expose a property on a resource class as a JSON:API relationship (https://jsonapi.org/format/#document-resource-object-relationships). - /// - [PublicAPI] - public abstract class RelationshipAttribute : ResourceFieldAttribute - { - /// - /// The property name of the EF Core inverse navigation, which may or may not exist. Even if it exists, it may not be exposed as a JSON:API relationship. - /// - /// - /// Articles { get; set; } - /// } - /// ]]> - /// - public PropertyInfo InverseNavigationProperty { get; set; } - - /// - /// The internal navigation property path to the related resource. - /// - /// - /// In all cases except for relationships, this equals the property name. - /// - public virtual string RelationshipPath => Property.Name; - - /// - /// The child resource type. This does not necessarily match the navigation property type. In the case of a relationship, - /// this value will be the collection element type. - /// - /// - /// Tags { get; set; } // Type => Tag - /// ]]> - /// - public Type RightType { get; internal set; } - - /// - /// The parent resource type. This is the type of the class in which this attribute was used. - /// - public Type LeftType { get; internal set; } - - /// - /// Configures which links to show in the object for this relationship. Defaults to - /// , which falls back to and then falls back to - /// . - /// - public LinkTypes Links { get; set; } = LinkTypes.NotConfigured; - - /// - /// Whether or not this relationship can be included using the - /// - /// ?include=publicName - /// - /// query string parameter. This is true by default. - /// - public bool CanInclude { get; set; } = true; - - /// - /// Gets the value of the resource property this attribute was declared on. - /// - public virtual object GetValue(object resource) - { - ArgumentGuard.NotNull(resource, nameof(resource)); - - return Property.GetValue(resource); - } - - /// - /// Sets the value of the resource property this attribute was declared on. - /// - public virtual void SetValue(object resource, object newValue) - { - ArgumentGuard.NotNull(resource, nameof(resource)); - - Property.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 = (RelationshipAttribute)obj; - - return LeftType == other.LeftType && RightType == other.RightType && Links == other.Links && CanInclude == other.CanInclude && base.Equals(other); - } - - public override int GetHashCode() - { - return HashCode.Combine(LeftType, RightType, Links, CanInclude, base.GetHashCode()); - } - } -} diff --git a/src/JsonApiDotNetCore/Resources/Annotations/ResourceAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/ResourceAttribute.cs deleted file mode 100644 index 501d085d57..0000000000 --- a/src/JsonApiDotNetCore/Resources/Annotations/ResourceAttribute.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Resources.Annotations -{ - /// - /// When put on a resource class, overrides the convention-based resource name. - /// - [PublicAPI] - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface)] - public sealed class ResourceAttribute : Attribute - { - /// - /// The publicly exposed name of this resource type. When not explicitly assigned, the configured naming convention is applied on the pluralized resource - /// class name. - /// - public string PublicName { get; } - - public ResourceAttribute(string publicName) - { - ArgumentGuard.NotNullNorEmpty(publicName, nameof(publicName)); - - PublicName = publicName; - } - } -} diff --git a/src/JsonApiDotNetCore/Resources/Annotations/ResourceFieldAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/ResourceFieldAttribute.cs deleted file mode 100644 index 93efe49696..0000000000 --- a/src/JsonApiDotNetCore/Resources/Annotations/ResourceFieldAttribute.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System; -using System.Reflection; -using JetBrains.Annotations; - -// ReSharper disable NonReadonlyMemberInGetHashCode - -namespace JsonApiDotNetCore.Resources.Annotations -{ - /// - /// Used to expose a property on a resource class as a JSON:API field (attribute or relationship). See - /// https://jsonapi.org/format/#document-resource-object-fields. - /// - [PublicAPI] - public abstract class ResourceFieldAttribute : Attribute - { - private string _publicName; - - /// - /// The publicly exposed name of this JSON:API field. When not explicitly assigned, the configured naming convention is applied on the property name. - /// - public string PublicName - { - get => _publicName; - set - { - if (string.IsNullOrWhiteSpace(value)) - { - throw new ArgumentException("Exposed name cannot be null, empty or contain only whitespace.", nameof(value)); - } - - _publicName = value; - } - } - - /// - /// The resource property that this attribute is declared on. - /// - public PropertyInfo Property { get; internal set; } - - public override string ToString() - { - return PublicName ?? (Property != null ? Property.Name : base.ToString()); - } - - public override bool Equals(object obj) - { - if (ReferenceEquals(this, obj)) - { - return true; - } - - if (obj is null || GetType() != obj.GetType()) - { - return false; - } - - var other = (ResourceFieldAttribute)obj; - - return PublicName == other.PublicName && Property == other.Property; - } - - public override int GetHashCode() - { - return HashCode.Combine(PublicName, Property); - } - } -} diff --git a/src/JsonApiDotNetCore/Resources/Annotations/ResourceLinksAttribute.cs b/src/JsonApiDotNetCore/Resources/Annotations/ResourceLinksAttribute.cs deleted file mode 100644 index f4d3a1ffc7..0000000000 --- a/src/JsonApiDotNetCore/Resources/Annotations/ResourceLinksAttribute.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; - -namespace JsonApiDotNetCore.Resources.Annotations -{ - /// - /// When put on a resource class, overrides global configuration for which links to render. - /// - [PublicAPI] - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Interface)] - public sealed class ResourceLinksAttribute : Attribute - { - /// - /// Configures which links to show in the object for this resource type. Defaults to - /// , which falls back to . - /// - public LinkTypes TopLevelLinks { get; set; } = LinkTypes.NotConfigured; - - /// - /// Configures which links to show in the object for this resource type. Defaults to - /// , which falls back to . - /// - public LinkTypes ResourceLinks { get; set; } = LinkTypes.NotConfigured; - - /// - /// Configures which links to show in the object for all relationships of this resource type. - /// Defaults to , which falls back to . This can be overruled per - /// relationship by setting . - /// - public LinkTypes RelationshipLinks { get; set; } = LinkTypes.NotConfigured; - } -} diff --git a/src/JsonApiDotNetCore/Resources/IAbstractResourceWrapper.cs b/src/JsonApiDotNetCore/Resources/IAbstractResourceWrapper.cs new file mode 100644 index 0000000000..7e9ac3c71d --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/IAbstractResourceWrapper.cs @@ -0,0 +1,12 @@ +namespace JsonApiDotNetCore.Resources; + +/// +/// Because an instance cannot be created from an abstract resource type, this wrapper is used to preserve that information. +/// +internal interface IAbstractResourceWrapper : IIdentifiable +{ + /// + /// The abstract resource type. + /// + Type AbstractType { get; } +} diff --git a/src/JsonApiDotNetCore/Resources/IIdentifiable.cs b/src/JsonApiDotNetCore/Resources/IIdentifiable.cs deleted file mode 100644 index 99559870a4..0000000000 --- a/src/JsonApiDotNetCore/Resources/IIdentifiable.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace JsonApiDotNetCore.Resources -{ - /// - /// When implemented by a class, indicates to JsonApiDotNetCore that the class represents a JSON:API resource. Note that JsonApiDotNetCore also assumes - /// that a property named 'Id' exists. - /// - 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). - /// - TId Id { get; set; } - } -} diff --git a/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs index 79eca2ed6a..13404f2759 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs @@ -1,34 +1,36 @@ -namespace JsonApiDotNetCore.Resources +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 { /// - /// Used to determine whether additional changes to a resource (side effects), not specified in a POST or PATCH request, have been applied. + /// Sets the exposed resource attributes as stored in database, before applying the PATCH operation. For POST operations, this sets exposed resource + /// attributes to their default value. /// - public interface IResourceChangeTracker - where TResource : class, IIdentifiable - { - /// - /// Sets the exposed resource attributes as stored in database, before applying the PATCH operation. For POST operations, this sets exposed resource - /// attributes to their default value. - /// - void SetInitiallyStoredAttributeValues(TResource resource); + void SetInitiallyStoredAttributeValues(TResource resource); - /// - /// Sets the (subset of) exposed resource attributes from the POST or PATCH request. - /// - void SetRequestedAttributeValues(TResource resource); + /// + /// Sets the (subset of) exposed resource attributes from the POST or PATCH request. + /// + void SetRequestAttributeValues(TResource resource); - /// - /// Sets the exposed resource attributes as stored in database, after applying the POST or PATCH operation. - /// - void SetFinallyStoredAttributeValues(TResource resource); + /// + /// Sets the exposed resource attributes as stored in database, after applying the POST or PATCH operation. + /// + void SetFinallyStoredAttributeValues(TResource resource); - /// - /// Validates if any exposed resource attributes that were not in the POST or PATCH request have been changed. And validates if the values from the - /// request are stored without modification. - /// - /// - /// true if the attribute values from the POST or PATCH request were the only changes; false, otherwise. - /// - bool HasImplicitChanges(); - } + /// + /// Validates if any exposed resource attributes that were not in the POST or PATCH request have been changed. And validates if the values from the + /// request are stored without modification. + /// + /// + /// true if the attribute values from the POST or PATCH request were the only changes; false, otherwise. + /// + bool HasImplicitChanges(); } diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs index 8664d98914..41d366b5c3 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinition.cs @@ -1,341 +1,340 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; +using System.Collections.Immutable; using JetBrains.Annotations; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Resources +namespace JsonApiDotNetCore.Resources; + +/// +/// Provides an extensibility point to add business logic that is resource-oriented instead of endpoint-oriented. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +[PublicAPI] +public interface IResourceDefinition + where TResource : class, IIdentifiable { /// - /// Provides an extensibility point to add business logic that is resource-oriented instead of endpoint-oriented. + /// Enables to extend, replace or remove includes that are being applied on this resource type. /// - /// - /// The resource type. - /// - [PublicAPI] - public interface IResourceDefinition : IResourceDefinition - where TResource : class, IIdentifiable - { - } + /// + /// An optional existing set of includes, coming from query string. Never null, but may be empty. + /// + /// + /// The new set of includes. Return an empty collection to remove all inclusions (never return null). + /// + IImmutableSet OnApplyIncludes(IImmutableSet existingIncludes); /// - /// Provides an extensibility point to add business logic that is resource-oriented instead of endpoint-oriented. + /// Enables to extend, replace or remove a filter that is being applied on a set of this resource type. /// - /// - /// The resource type. - /// - /// - /// The resource identifier type. - /// - [PublicAPI] - // ReSharper disable once TypeParameterCanBeVariant -- Justification: making TId contravariant is a breaking change. - public interface IResourceDefinition - where TResource : class, IIdentifiable - { - /// - /// Enables to extend, replace or remove includes that are being applied on this resource type. - /// - /// - /// An optional existing set of includes, coming from query string. Never null, but may be empty. - /// - /// - /// The new set of includes. Return an empty collection to remove all inclusions (never return null). - /// - IReadOnlyCollection OnApplyIncludes(IReadOnlyCollection existingIncludes); - - /// - /// Enables to extend, replace or remove a filter that is being applied on a set of this resource type. - /// - /// - /// An optional existing filter, coming from query string. Can be null. - /// - /// - /// The new filter, or null to disable the existing filter. - /// - FilterExpression OnApplyFilter(FilterExpression existingFilter); + /// + /// An optional existing filter, coming from query string. Can be null. + /// + /// + /// The new filter, or null to disable the existing filter. + /// + FilterExpression? OnApplyFilter(FilterExpression? existingFilter); - /// - /// Enables to extend, replace or remove a sort order that is being applied on a set of this resource type. Tip: Use - /// to build from a lambda expression. - /// - /// - /// An optional existing sort order, coming from query string. Can be null. - /// - /// - /// The new sort order, or null to disable the existing sort order and sort by ID. - /// - SortExpression OnApplySort(SortExpression existingSort); + /// + /// Enables to extend, replace or remove a sort order that is being applied on a set of this resource type. Tip: Use + /// to build from a lambda expression. + /// + /// + /// An optional existing sort order, coming from query string. Can be null. + /// + /// + /// The new sort order, or null to disable the existing sort order and sort by ID. + /// + SortExpression? OnApplySort(SortExpression? existingSort); - /// - /// Enables to extend, replace or remove pagination that is being applied on a set of this resource type. - /// - /// - /// 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 - /// to null. - /// - PaginationExpression OnApplyPagination(PaginationExpression existingPagination); + /// + /// Enables to extend, replace or remove pagination that is being applied on a set of this resource type. + /// + /// + /// 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 pagination, set + /// to null. + /// + PaginationExpression? OnApplyPagination(PaginationExpression? existingPagination); - /// - /// Enables to extend, replace or remove a sparse fieldset that is being applied on a set of this resource type. Tip: Use - /// and to - /// safely change the fieldset without worrying about nulls. - /// - /// - /// This method executes twice for a single request: first to select which fields to retrieve from the data store and then to select which fields to - /// serialize. Including extra fields from this method will retrieve them, but not include them in the json output. This enables you to expose calculated - /// properties whose value depends on a field that is not in the sparse fieldset. - /// - /// - /// The incoming sparse fieldset from query string. At query execution time, this is null if the query string contains no sparse fieldset. At - /// serialization time, this contains all viewable fields if the query string contains no sparse fieldset. - /// - /// - /// The new sparse fieldset, or null to discard the existing sparse fieldset and select all viewable fields. - /// - SparseFieldSetExpression OnApplySparseFieldSet(SparseFieldSetExpression existingSparseFieldSet); + /// + /// Enables to extend, replace or remove a sparse fieldset that is being applied on a set of this resource type. Tip: Use + /// and to + /// safely change the fieldset without worrying about nulls. + /// + /// + /// This method executes twice for a single request: first to select which fields to retrieve from the data store and then to select which fields to + /// serialize. Including extra fields from this method will retrieve them, but not include them in the json output. This enables you to expose calculated + /// properties whose value depends on a field that is not in the sparse fieldset. + /// + /// + /// The incoming sparse fieldset from query string. At query execution time, this is null if the query string contains no sparse fieldset. At + /// serialization time, this contains all viewable fields if the query string contains no sparse fieldset. + /// + /// + /// The new sparse fieldset, or null to discard the existing sparse fieldset and select all viewable fields. + /// + SparseFieldSetExpression? OnApplySparseFieldSet(SparseFieldSetExpression? existingSparseFieldSet); - /// - /// Enables to adapt the Entity Framework Core query, based on custom query string parameters. Note this only works on - /// primary resource requests, such as /articles, but not on /blogs/1/articles or /blogs?include=articles. - /// - /// - /// source - /// .Include(model => model.Children) - /// .Where(model => model.LastUpdateTime > DateTime.Now.AddMonths(-1)), - /// ["isHighRisk"] = FilterByHighRisk - /// }; - /// } - /// - /// private static IQueryable FilterByHighRisk(IQueryable source, StringValues parameterValue) - /// { - /// bool isFilterOnHighRisk = bool.Parse(parameterValue); - /// return isFilterOnHighRisk ? source.Where(model => model.RiskLevel >= 5) : source.Where(model => model.RiskLevel < 5); - /// } - /// ]]> - /// -#pragma warning disable AV1130 // Return type in method signature should be a collection interface instead of a concrete type - QueryStringParameterHandlers OnRegisterQueryableHandlersForQueryStringParameters(); -#pragma warning restore AV1130 // Return type in method signature should be a collection interface instead of a concrete type + /// + /// Enables to adapt the Entity Framework Core query, based on custom query string parameters. Note this only works on + /// primary resource requests, such as /articles, but not on /blogs/1/articles or /blogs?include=articles. + /// + /// + /// source + /// .Include(model => model.Children) + /// .Where(model => model.LastUpdateTime > DateTime.Now.AddMonths(-1)), + /// ["isHighRisk"] = FilterByHighRisk + /// }; + /// } + /// + /// private static IQueryable FilterByHighRisk(IQueryable source, StringValues parameterValue) + /// { + /// bool isFilterOnHighRisk = bool.Parse(parameterValue); + /// return isFilterOnHighRisk ? source.Where(model => model.RiskLevel >= 5) : source.Where(model => model.RiskLevel < 5); + /// } + /// ]]> + /// +#pragma warning disable AV1130 // Return type in method signature should be an interface to an unchangeable collection + QueryStringParameterHandlers? OnRegisterQueryableHandlersForQueryStringParameters(); +#pragma warning restore AV1130 // Return type in method signature should be an interface to an unchangeable collection - /// - /// Enables to add JSON:API meta information, specific to this resource. - /// - IDictionary GetMeta(TResource resource); + /// + /// Enables to add JSON:API meta information, specific to this resource. + /// +#pragma warning disable AV1130 // Return type in method signature should be an interface to an unchangeable collection + IDictionary? GetMeta(TResource resource); +#pragma warning restore AV1130 // Return type in method signature should be an interface to an unchangeable collection - /// - /// Executes after the original version of the resource has been retrieved from the underlying data store, as part of a write request. - /// - /// Implementing this method enables to perform validations and make changes to , before the fields from the request are - /// copied into it. - /// - /// - /// For POST resource requests, this method is typically used to assign property default values or to set required relationships by side-loading the - /// related resources and linking them. - /// - /// - /// - /// The original resource retrieved from the underlying data store, or a freshly instantiated resource in case of a POST resource request. - /// - /// - /// Identifies from which endpoint this method was called. Possible values: , - /// , and . - /// Note this intentionally excludes and , because for those - /// endpoints no resource is retrieved upfront. - /// - /// - /// Propagates notification that request handling should be canceled. - /// - Task OnPrepareWriteAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken); + /// + /// Executes after the original version of the resource has been retrieved from the underlying data store, as part of a write request. + /// + /// Implementing this method enables to perform validations and make changes to , before the fields from the request are + /// copied into it. + /// + /// + /// For POST resource requests, this method is typically used to assign property default values or to set required relationships by side-loading the + /// related resources and linking them. + /// + /// + /// + /// The original resource retrieved from the underlying data store, or a freshly instantiated resource in case of a POST resource request. + /// + /// + /// Identifies the logical write operation for which this method was called. Possible values: , + /// , and + /// . Note this intentionally excludes and + /// , because for those endpoints no resource is retrieved upfront. + /// + /// + /// Propagates notification that request handling should be canceled. + /// + Task OnPrepareWriteAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken); - /// - /// Executes before setting (or clearing) the resource at the right side of a to-one relationship. - /// - /// Implementing this method enables to perform validations and change , before the relationship is updated. - /// - /// - /// - /// The original resource as retrieved from the underlying data store. The indication "left" specifies that is - /// declared on . - /// - /// - /// The to-one relationship being set. - /// - /// - /// The new resource identifier (or null to clear the relationship), coming from the request. - /// - /// - /// Identifies from which endpoint this method was called. Possible values: , - /// and . - /// - /// - /// Propagates notification that request handling should be canceled. - /// - /// - /// The replacement resource identifier, or null to clear the relationship. Returns by default. - /// - Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, IIdentifiable rightResourceId, - OperationKind operationKind, CancellationToken cancellationToken); + /// + /// Executes before setting (or clearing) the resource at the right side of a to-one relationship. + /// + /// Implementing this method enables to perform validations and change , before the relationship is updated. + /// + /// + /// + /// The original resource as retrieved from the underlying data store. The indication "left" specifies that is + /// declared on . + /// + /// + /// The to-one relationship being set. + /// + /// + /// The new resource identifier (or null to clear the relationship), coming from the request. + /// + /// + /// Identifies the logical write operation for which this method was called. Possible values: , + /// and . + /// + /// + /// Propagates notification that request handling should be canceled. + /// + /// + /// The replacement resource identifier, or null to clear the relationship. Returns by default. + /// + Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, IIdentifiable? rightResourceId, + WriteOperationKind writeOperation, CancellationToken cancellationToken); - /// - /// Executes before setting the resources at the right side of a to-many relationship. This replaces on existing set. - /// - /// Implementing this method enables to perform validations and make changes to , before the relationship is updated. - /// - /// - /// - /// The original resource as retrieved from the underlying data store. The indication "left" specifies that is - /// declared on . - /// - /// - /// The to-many relationship being set. - /// - /// - /// The set of resource identifiers to replace any existing set with, coming from the request. - /// - /// - /// Identifies from which endpoint this method was called. Possible values: , - /// and . - /// - /// - /// Propagates notification that request handling should be canceled. - /// - Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - OperationKind operationKind, CancellationToken cancellationToken); + /// + /// Executes before setting the resources at the right side of a to-many relationship. This replaces on existing set. + /// + /// Implementing this method enables to perform validations and make changes to , before the relationship is updated. + /// + /// + /// + /// The original resource as retrieved from the underlying data store. The indication "left" specifies that is + /// declared on . + /// + /// + /// The to-many relationship being set. + /// + /// + /// The set of resource identifiers to replace any existing set with, coming from the request. + /// + /// + /// Identifies the logical write operation for which this method was called. Possible values: , + /// and . + /// + /// + /// Propagates notification that request handling should be canceled. + /// + Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + WriteOperationKind writeOperation, CancellationToken cancellationToken); - /// - /// Executes before adding resources to the right side of a to-many relationship, as part of a POST relationship request. - /// - /// Implementing this method enables to perform validations and make changes to , before the relationship is updated. - /// - /// - /// - /// Identifier of the left resource. The indication "left" specifies that is declared on - /// . - /// - /// - /// The to-many relationship being added to. - /// - /// - /// The set of resource identifiers to add to the to-many relationship, coming from the request. - /// - /// - /// Propagates notification that request handling should be canceled. - /// - Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - CancellationToken cancellationToken); + /// + /// Executes before adding resources to the right side of a to-many relationship, as part of a POST relationship request. + /// + /// Implementing this method enables to perform validations and make changes to , before the relationship is updated. + /// + /// + /// + /// Identifier of the left resource. The indication "left" specifies that is declared on + /// . In contrast to other relationship methods, this value is not retrieved from the underlying data store, except in + /// the following two cases: + /// + /// + /// + /// is a many-to-many relationship. This is required to prevent failure when already assigned. + /// + /// + /// + /// + /// The left resource type is part of a type hierarchy. This ensures your business logic runs against the actually stored type. + /// + /// + /// + /// + /// + /// The to-many relationship being added to. + /// + /// + /// The set of resource identifiers to add to the to-many relationship, coming from the request. + /// + /// + /// Propagates notification that request handling should be canceled. + /// + Task OnAddToRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken); - /// - /// Executes before removing resources from the right side of a to-many relationship, as part of a DELETE relationship request. - /// - /// Implementing this method enables to perform validations and make changes to , before the relationship is updated. - /// - /// - /// - /// The original resource as retrieved from the underlying data store. The indication "left" specifies that is - /// declared on . - /// - /// - /// The to-many relationship being removed from. - /// - /// - /// The set of resource identifiers to remove from the to-many relationship, coming from the request. - /// - /// - /// Propagates notification that request handling should be canceled. - /// - Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - CancellationToken cancellationToken); + /// + /// Executes before removing resources from the right side of a to-many relationship, as part of a DELETE relationship request. + /// + /// Implementing this method enables to perform validations and make changes to , before the relationship is updated. + /// + /// + /// + /// 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. + /// + /// + /// The set of resource identifiers to remove from the to-many relationship, coming from the request. + /// + /// + /// Propagates notification that request handling should be canceled. + /// + Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken); - /// - /// Executes before writing the changed resource to the underlying data store, as part of a write request. - /// - /// Implementing this method enables to perform validations and make changes to , after the fields from the request have been - /// copied into it. - /// - /// - /// An example usage is to set the last-modification timestamp, overwriting the value from the incoming request. - /// - /// - /// Another use case is to add a notification message to an outbox table, which gets committed along with the resource write in a single transaction (see - /// https://microservices.io/patterns/data/transactional-outbox.html). - /// - /// - /// - /// The original resource retrieved from the underlying data store (or a freshly instantiated resource in case of a POST resource request), updated with - /// the changes from the incoming request. Exception: In case is or - /// , this is an empty object with only the property set, because for - /// those endpoints no resource is retrieved upfront. - /// - /// - /// Identifies from which endpoint this method was called. Possible values: , - /// , , , - /// and . - /// - /// - /// Propagates notification that request handling should be canceled. - /// - Task OnWritingAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken); + /// + /// Executes before writing the changed resource to the underlying data store, as part of a write request. + /// + /// Implementing this method enables to perform validations and make changes to , after the fields from the request have been + /// copied into it. + /// + /// + /// An example usage is to set the last-modification timestamp, overwriting the value from the incoming request. + /// + /// + /// Another use case is to add a notification message to an outbox table, which gets committed along with the resource write in a single transaction (see + /// https://microservices.io/patterns/data/transactional-outbox.html). + /// + /// + /// + /// The original resource retrieved from the underlying data store (or a freshly instantiated resource in case of a POST resource request), updated with + /// the changes from the incoming request. Exception: In case is , + /// or , this is an empty object with only + /// the property set, because for those endpoints no resource is retrieved upfront. + /// + /// + /// Identifies the logical write operation for which this method was called. Possible values: , + /// , , + /// , and . + /// + /// + /// Propagates notification that request handling should be canceled. + /// + Task OnWritingAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken); - /// - /// Executes after successfully writing the changed resource to the underlying data store, as part of a write request. - /// - /// Implementing this method enables to run additional logic, for example enqueue a notification message on a service bus. - /// - /// - /// - /// The resource as written to the underlying data store. - /// - /// - /// Identifies from which endpoint this method was called. Possible values: , - /// , , , - /// and . - /// - /// - /// Propagates notification that request handling should be canceled. - /// - Task OnWriteSucceededAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken); + /// + /// Executes after successfully writing the changed resource to the underlying data store, as part of a write request. + /// + /// Implementing this method enables to run additional logic, for example enqueue a notification message on a service bus. + /// + /// + /// + /// The resource as written to the underlying data store. + /// + /// + /// Identifies the logical write operation for which this method was called. Possible values: , + /// , , + /// , and . + /// + /// + /// Propagates notification that request handling should be canceled. + /// + Task OnWriteSucceededAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken); - /// - /// Executes after a resource has been deserialized from an incoming request body. - /// - /// - /// Implementing this method enables to change the incoming resource before it enters an ASP.NET Controller Action method. - /// - /// - /// Changing attributes on from this method may break detection of side effects on resource POST/PATCH requests, because - /// side effect detection considers any changes done from this method to be part of the incoming request body. So setting additional attributes from this - /// method (that were not sent by the client) are not considered side effects, resulting in incorrectly reporting that there were no side effects. - /// - /// - /// The deserialized resource. - /// - void OnDeserialize(TResource resource); + /// + /// Executes after a resource has been deserialized from an incoming request body. + /// + /// + /// Implementing this method enables to change the incoming resource before it enters an ASP.NET Controller Action method. + /// + /// + /// Changing attributes on from this method may break detection of side effects on resource POST/PATCH requests, because + /// side effect detection considers any changes done from this method to be part of the incoming request body. So setting additional attributes from this + /// method (that were not sent by the client) are not considered side effects, resulting in incorrectly reporting that there were no side effects. + /// + /// + /// The deserialized resource. + /// + void OnDeserialize(TResource resource); - /// - /// Executes before a (primary or included) resource is serialized into an outgoing response body. - /// - /// - /// Implementing this method enables to change the returned resource, for example scrub sensitive data or transform returned attribute values. - /// - /// - /// Changing attributes on from this method may break detection of side effects on resource POST/PATCH requests. What this - /// means is that if side effects were detected before, this is not re-evaluated after running this method, so it may incorrectly report side effects if - /// they were undone by this method. - /// - /// - /// The serialized resource. - /// - void OnSerialize(TResource resource); - } + /// + /// Executes before a (primary or included) resource is serialized into an outgoing response body. + /// + /// + /// Implementing this method enables to change the returned resource, for example scrub sensitive data or transform returned attribute values. + /// + /// + /// Changing attributes on from this method may break detection of side effects on resource POST/PATCH requests. What this + /// means is that if side effects were detected before, this is not re-evaluated after running this method, so it may incorrectly report side effects if + /// they were undone by this method. + /// + /// + /// The serialized resource. + /// + void OnSerialize(TResource resource); } diff --git a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs index 042a5553c4..d16a6074b2 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceDefinitionAccessor.cs @@ -1,109 +1,126 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; +using System.Collections.Immutable; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.QueryableBuilding; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Resources +namespace JsonApiDotNetCore.Resources; + +/// +/// Retrieves an instance from the D/I container and invokes a callback on it. +/// +public interface IResourceDefinitionAccessor { /// - /// Retrieves an instance from the D/I container and invokes a callback on it. - /// - public interface IResourceDefinitionAccessor - { - /// - /// Invokes for the specified resource type. - /// - IReadOnlyCollection OnApplyIncludes(Type resourceType, IReadOnlyCollection existingIncludes); - - /// - /// Invokes for the specified resource type. - /// - FilterExpression OnApplyFilter(Type resourceType, FilterExpression existingFilter); - - /// - /// Invokes for the specified resource type. - /// - SortExpression OnApplySort(Type resourceType, SortExpression existingSort); - - /// - /// Invokes for the specified resource type. - /// - PaginationExpression OnApplyPagination(Type resourceType, PaginationExpression existingPagination); - - /// - /// Invokes for the specified resource type. - /// - SparseFieldSetExpression OnApplySparseFieldSet(Type resourceType, SparseFieldSetExpression existingSparseFieldSet); - - /// - /// Invokes for the specified resource type, then - /// returns the expression for the specified parameter name. - /// - object GetQueryableHandlerForQueryStringParameter(Type resourceType, string parameterName); - - /// - /// Invokes for the specified resource. - /// - IDictionary GetMeta(Type resourceType, IIdentifiable resourceInstance); - - /// - /// Invokes for the specified resource. - /// - Task OnPrepareWriteAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; - - /// - /// Invokes for the specified resource. - /// - public Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, - IIdentifiable rightResourceId, OperationKind operationKind, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; - - /// - /// Invokes for the specified resource. - /// - public Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - OperationKind operationKind, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; - - /// - /// Invokes for the specified resource. - /// - public Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - CancellationToken cancellationToken) - where TResource : class, IIdentifiable; - - /// - /// Invokes for the specified resource. - /// - public Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - CancellationToken cancellationToken) - where TResource : class, IIdentifiable; - - /// - /// Invokes for the specified resource. - /// - Task OnWritingAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; - - /// - /// Invokes for the specified resource. - /// - Task OnWriteSucceededAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) - where TResource : class, IIdentifiable; - - /// - /// Invokes for the specified resource. - /// - void OnDeserialize(IIdentifiable resource); - - /// - /// Invokes for the specified resource. - /// - void OnSerialize(IIdentifiable resource); - } + /// 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. + /// + IImmutableSet OnApplyIncludes(ResourceType resourceType, IImmutableSet existingIncludes); + + /// + /// Invokes for the specified resource type. + /// + FilterExpression? OnApplyFilter(ResourceType resourceType, FilterExpression? existingFilter); + + /// + /// Invokes for the specified resource type. + /// + SortExpression? OnApplySort(ResourceType resourceType, SortExpression? existingSort); + + /// + /// Invokes for the specified resource type. + /// + PaginationExpression? OnApplyPagination(ResourceType resourceType, PaginationExpression? existingPagination); + + /// + /// Invokes for the specified resource type. + /// + SparseFieldSetExpression? OnApplySparseFieldSet(ResourceType resourceType, SparseFieldSetExpression? existingSparseFieldSet); + + /// + /// Invokes for the specified resource type, then + /// returns the expression for the specified parameter name. + /// + object? GetQueryableHandlerForQueryStringParameter(Type resourceClrType, string parameterName); + + /// + /// Invokes for the specified resource. + /// +#pragma warning disable AV1130 // Return type in method signature should be an interface to an unchangeable collection + IDictionary? GetMeta(ResourceType resourceType, IIdentifiable resourceInstance); +#pragma warning restore AV1130 // Return type in method signature should be an interface to an unchangeable collection + + /// + /// Invokes for the specified resource. + /// + Task OnPrepareWriteAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; + + /// + /// Invokes for the specified resource. + /// + public Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, + IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; + + /// + /// Invokes for the specified resource. + /// + public Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; + + /// + /// Invokes for the specified resource. + /// + public Task OnAddToRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + where TResource : class, IIdentifiable; + + /// + /// Invokes for the specified resource. + /// + public Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + where TResource : class, IIdentifiable; + + /// + /// Invokes for the specified resource. + /// + Task OnWritingAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; + + /// + /// Invokes for the specified resource. + /// + Task OnWriteSucceededAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable; + + /// + /// Invokes for the specified resource. + /// + void OnDeserialize(IIdentifiable resource); + + /// + /// Invokes for the specified resource. + /// + void OnSerialize(IIdentifiable resource); } diff --git a/src/JsonApiDotNetCore/Resources/IResourceFactory.cs b/src/JsonApiDotNetCore/Resources/IResourceFactory.cs index cd520b4f6f..2906049e84 100644 --- a/src/JsonApiDotNetCore/Resources/IResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/IResourceFactory.cs @@ -1,35 +1,25 @@ -using System; using System.Linq.Expressions; -using JsonApiDotNetCore.Repositories; -namespace JsonApiDotNetCore.Resources +namespace JsonApiDotNetCore.Resources; + +/// +/// Creates object instances for resource classes, which may have injectable dependencies. +/// +public interface IResourceFactory { /// - /// Creates object instances for resource classes, which may have injectable dependencies. + /// Creates a new resource object instance. /// - public interface IResourceFactory - { - /// - /// Creates a new resource object instance. - /// - public IIdentifiable CreateInstance(Type resourceType); - - /// - /// Creates a new resource object instance. - /// - public TResource CreateInstance() - where TResource : IIdentifiable; + public IIdentifiable CreateInstance(Type resourceClrType); - /// - /// Returns an expression tree that represents creating a new resource object instance. - /// - public NewExpression CreateNewExpression(Type resourceType); + /// + /// Creates a new resource object instance. + /// + public TResource CreateInstance() + where TResource : IIdentifiable; - /// - /// Provides access to the request-scoped instance. This method has been added solely to prevent introducing a - /// breaking change in the constructor and will be removed in the next major version. - /// - [Obsolete] - IResourceDefinitionAccessor GetResourceDefinitionAccessor(); - } + /// + /// Returns an expression tree that represents creating a new resource object instance. + /// + public NewExpression CreateNewExpression(Type resourceClrType); } diff --git a/src/JsonApiDotNetCore/Resources/ITargetedFields.cs b/src/JsonApiDotNetCore/Resources/ITargetedFields.cs index 5cdb36950d..32226e214d 100644 --- a/src/JsonApiDotNetCore/Resources/ITargetedFields.cs +++ b/src/JsonApiDotNetCore/Resources/ITargetedFields.cs @@ -1,21 +1,24 @@ -using System.Collections.Generic; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Resources +namespace JsonApiDotNetCore.Resources; + +/// +/// Container to register which resource fields (attributes and relationships) are targeted by a request. +/// +public interface ITargetedFields { /// - /// Container to register which resource attributes and relationships are targeted by a request. + /// The set of attributes that are targeted by a request. + /// + IReadOnlySet Attributes { get; } + + /// + /// The set of relationships that are targeted by a request. /// - public interface ITargetedFields - { - /// - /// The set of attributes that are targeted by a request. - /// - ISet Attributes { get; set; } + IReadOnlySet Relationships { get; } - /// - /// The set of relationships that are targeted by a request. - /// - ISet Relationships { get; set; } - } + /// + /// Performs a shallow copy. + /// + void CopyFrom(ITargetedFields other); } diff --git a/src/JsonApiDotNetCore/Resources/Identifiable.cs b/src/JsonApiDotNetCore/Resources/Identifiable.cs deleted file mode 100644 index aada6b312a..0000000000 --- a/src/JsonApiDotNetCore/Resources/Identifiable.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations.Schema; -using JsonApiDotNetCore.Resources.Internal; - -namespace JsonApiDotNetCore.Resources -{ - /// - public abstract class Identifiable : Identifiable - { - } - - /// - /// A convenient basic implementation of that provides conversion between and . - /// - /// - /// The resource identifier type. - /// - public abstract class Identifiable : IIdentifiable - { - /// - public virtual TId Id { get; set; } - - /// - [NotMapped] - public string StringId - { - get => GetStringId(Id); - set => Id = GetTypedId(value); - } - - /// - [NotMapped] - public string LocalId { get; set; } - - /// - /// Converts an outgoing typed resource identifier to string format for use in a JSON:API response. - /// - protected virtual string GetStringId(TId value) - { - return EqualityComparer.Default.Equals(value, default) ? null : value.ToString(); - } - - /// - /// Converts an incoming 'id' element from a JSON:API request to the typed resource identifier. - /// - protected virtual TId GetTypedId(string value) - { - return value == null ? default : (TId)RuntimeTypeConverter.ConvertType(value, typeof(TId)); - } - } -} diff --git a/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs b/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs index 332995b294..8a6bed2f91 100644 --- a/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs +++ b/src/JsonApiDotNetCore/Resources/IdentifiableComparer.cs @@ -1,46 +1,43 @@ -using System; -using System.Collections.Generic; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Resources +namespace JsonApiDotNetCore.Resources; + +/// +/// Compares `IIdentifiable` instances with each other based on their type and , falling back to +/// when both StringIds are null. +/// +[PublicAPI] +public sealed class IdentifiableComparer : IEqualityComparer { - /// - /// Compares `IIdentifiable` instances with each other based on their type and , falling back to - /// when both StringIds are null. - /// - [PublicAPI] - public sealed class IdentifiableComparer : IEqualityComparer + public static readonly IdentifiableComparer Instance = new(); + + private IdentifiableComparer() { - public static readonly IdentifiableComparer Instance = new IdentifiableComparer(); + } - private IdentifiableComparer() + public bool Equals(IIdentifiable? left, IIdentifiable? right) + { + if (ReferenceEquals(left, right)) { + return true; } - public bool Equals(IIdentifiable x, IIdentifiable y) + if (left is null || right is null || left.GetClrType() != right.GetClrType()) { - if (ReferenceEquals(x, y)) - { - return true; - } - - if (x is null || y is null || x.GetType() != y.GetType()) - { - return false; - } - - if (x.StringId == null && y.StringId == null) - { - return x.LocalId == y.LocalId; - } - - return x.StringId == y.StringId; + return false; } - public int GetHashCode(IIdentifiable obj) + if (left.StringId == null && right.StringId == null) { - // LocalId is intentionally omitted here, it is okay for hashes to collide. - return HashCode.Combine(obj.GetType(), obj.StringId); + return left.LocalId == right.LocalId; } + + return left.StringId == right.StringId; + } + + public int GetHashCode(IIdentifiable obj) + { + // LocalId is intentionally omitted here, it is okay for hashes to collide. + return HashCode.Combine(obj.GetClrType(), obj.StringId); } } diff --git a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs index 807d6c3f23..d4e3c156cb 100644 --- a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs +++ b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs @@ -1,22 +1,38 @@ -using System; using System.Reflection; -namespace JsonApiDotNetCore.Resources +namespace JsonApiDotNetCore.Resources; + +internal static class IdentifiableExtensions { - internal static class IdentifiableExtensions + private const string IdPropertyName = nameof(Identifiable.Id); + + public static object GetTypedId(this IIdentifiable identifiable) { - public static object GetTypedId(this IIdentifiable identifiable) - { - ArgumentGuard.NotNull(identifiable, nameof(identifiable)); + ArgumentNullException.ThrowIfNull(identifiable); - PropertyInfo property = identifiable.GetType().GetProperty(nameof(Identifiable.Id)); + PropertyInfo? property = identifiable.GetClrType().GetProperty(IdPropertyName); + + if (property == null) + { + throw new InvalidOperationException($"Resource of type '{identifiable.GetClrType()}' does not contain a property named '{IdPropertyName}'."); + } - if (property == null) - { - throw new InvalidOperationException($"Resource of type '{identifiable.GetType()}' does not have an 'Id' property."); - } + object? propertyValue = property.GetValue(identifiable); + object? defaultValue = RuntimeTypeConverter.GetDefaultValue(property.PropertyType); - return property.GetValue(identifiable); + 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}'."); } + + return propertyValue!; + } + + public static Type GetClrType(this IIdentifiable identifiable) + { + ArgumentNullException.ThrowIfNull(identifiable); + + return identifiable is IAbstractResourceWrapper abstractResource ? abstractResource.AbstractType : identifiable.GetType(); } } diff --git a/src/JsonApiDotNetCore/Resources/Internal/RuntimeTypeConverter.cs b/src/JsonApiDotNetCore/Resources/Internal/RuntimeTypeConverter.cs deleted file mode 100644 index b05acd5eba..0000000000 --- a/src/JsonApiDotNetCore/Resources/Internal/RuntimeTypeConverter.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System; -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)) - { - throw new FormatException($"Failed to convert 'null' to type '{type.Name}'."); - } - - 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(DateTimeOffset)) - { - DateTimeOffset convertedValue = DateTimeOffset.Parse(stringValue); - 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 || exception is OverflowException || exception is InvalidCastException || - exception is ArgumentException) - { - throw new FormatException($"Failed to convert '{value}' of type '{runtimeType.Name}' to type '{type.Name}'.", 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/Resources/JsonApiResourceDefinition.cs b/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs index 3498d74a7a..e2a354ca76 100644 --- a/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs +++ b/src/JsonApiDotNetCore/Resources/JsonApiResourceDefinition.cs @@ -1,189 +1,181 @@ -using System; -using System.Collections.Generic; +using System.Collections.Immutable; using System.ComponentModel; -using System.Linq; using System.Linq.Expressions; -using System.Threading; -using System.Threading.Tasks; +using System.Net; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; -namespace JsonApiDotNetCore.Resources +namespace JsonApiDotNetCore.Resources; + +/// +[PublicAPI] +public class JsonApiResourceDefinition : IResourceDefinition + where TResource : class, IIdentifiable { + protected IResourceGraph ResourceGraph { get; } + /// - /// Provides a resource-centric extensibility point for executing custom code when something happens with a resource. The goal here is to reduce the need - /// for overriding the service and repository layers. + /// Provides metadata for the resource type . /// - /// - /// The resource type. - /// - [PublicAPI] - public class JsonApiResourceDefinition : JsonApiResourceDefinition, IResourceDefinition - where TResource : class, IIdentifiable - { - public JsonApiResourceDefinition(IResourceGraph resourceGraph) - : base(resourceGraph) - { - } - } + protected ResourceType ResourceType { get; } - /// - [PublicAPI] - public class JsonApiResourceDefinition : IResourceDefinition - where TResource : class, IIdentifiable + public JsonApiResourceDefinition(IResourceGraph resourceGraph) { - protected IResourceGraph ResourceGraph { get; } + ArgumentNullException.ThrowIfNull(resourceGraph); - /// - /// Provides metadata for the resource type . - /// - protected ResourceContext ResourceContext { get; } + ResourceGraph = resourceGraph; + ResourceType = resourceGraph.GetResourceType(); + } - public JsonApiResourceDefinition(IResourceGraph resourceGraph) - { - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); + /// + public virtual IImmutableSet OnApplyIncludes(IImmutableSet existingIncludes) + { + return existingIncludes; + } - ResourceGraph = resourceGraph; - ResourceContext = resourceGraph.GetResourceContext(); - } + /// + public virtual FilterExpression? OnApplyFilter(FilterExpression? existingFilter) + { + return existingFilter; + } - /// - public virtual IReadOnlyCollection OnApplyIncludes(IReadOnlyCollection existingIncludes) - { - return existingIncludes; - } + /// + public virtual SortExpression? OnApplySort(SortExpression? existingSort) + { + return existingSort; + } - /// - public virtual FilterExpression OnApplyFilter(FilterExpression existingFilter) - { - return existingFilter; - } + /// + /// Creates a from a lambda expression. + /// + /// + /// blog.Author.Name.LastName, ListSortDirection.Ascending), + /// (blog => blog.Posts.Count, ListSortDirection.Descending), + /// (blog => blog.Title, ListSortDirection.Ascending) + /// }); + /// ]]> + /// + protected virtual SortExpression CreateSortExpressionFromLambda(PropertySortOrder keySelectors) + { + ArgumentGuard.NotNullNorEmpty(keySelectors); - /// - public virtual SortExpression OnApplySort(SortExpression existingSort) - { - return existingSort; - } + ImmutableArray.Builder elementsBuilder = ImmutableArray.CreateBuilder(keySelectors.Count); + var lambdaConverter = new SortExpressionLambdaConverter(ResourceGraph); - /// - /// Creates a from a lambda expression. - /// - /// - /// model.CreatedAt, ListSortDirection.Ascending), - /// (model => model.Password, ListSortDirection.Descending) - /// }); - /// ]]> - /// - protected SortExpression CreateSortExpressionFromLambda(PropertySortOrder keySelectors) + foreach ((Expression> keySelector, ListSortDirection sortDirection) in keySelectors) { - ArgumentGuard.NotNull(keySelectors, nameof(keySelectors)); - - var sortElements = new List(); - - foreach ((Expression> keySelector, ListSortDirection sortDirection) in keySelectors) + try { - bool isAscending = sortDirection == ListSortDirection.Ascending; - AttrAttribute attribute = ResourceGraph.GetAttributes(keySelector).Single(); - - var sortElement = new SortElementExpression(new ResourceFieldChainExpression(attribute), isAscending); - sortElements.Add(sortElement); + SortElementExpression sortElement = lambdaConverter.FromLambda(keySelector, sortDirection); + elementsBuilder.Add(sortElement); + } + catch (InvalidOperationException exception) + { + throw new JsonApiException(new ErrorObject(HttpStatusCode.InternalServerError) + { + Title = "Invalid lambda expression for sorting from resource definition. " + + "It should select a property that is exposed as an attribute, or a to-many relationship followed by Count(). " + + "The property can be preceded by a path of to-one relationships. " + + "Examples: 'blog => blog.Title', 'blog => blog.Posts.Count', 'blog => blog.Author.Name.LastName'.", + Detail = $"The lambda expression '{keySelector}' is invalid. {exception.Message}" + }, exception); } - - return new SortExpression(sortElements); } - /// - public virtual PaginationExpression OnApplyPagination(PaginationExpression existingPagination) - { - return existingPagination; - } + return new SortExpression(elementsBuilder.ToImmutable()); + } - /// - public virtual SparseFieldSetExpression OnApplySparseFieldSet(SparseFieldSetExpression existingSparseFieldSet) - { - return existingSparseFieldSet; - } + /// + public virtual PaginationExpression? OnApplyPagination(PaginationExpression? existingPagination) + { + return existingPagination; + } - /// - public virtual QueryStringParameterHandlers OnRegisterQueryableHandlersForQueryStringParameters() - { - return null; - } + /// + public virtual SparseFieldSetExpression? OnApplySparseFieldSet(SparseFieldSetExpression? existingSparseFieldSet) + { + return existingSparseFieldSet; + } - /// - public virtual IDictionary GetMeta(TResource resource) - { - return null; - } + /// + public virtual QueryStringParameterHandlers? OnRegisterQueryableHandlersForQueryStringParameters() + { + return null; + } - /// - public virtual Task OnPrepareWriteAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) - { - return Task.CompletedTask; - } + /// + public virtual IDictionary? GetMeta(TResource resource) + { + return null; + } - /// - public virtual Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, - IIdentifiable rightResourceId, OperationKind operationKind, CancellationToken cancellationToken) - { - return Task.FromResult(rightResourceId); - } + /// + public virtual Task OnPrepareWriteAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } - /// - public virtual Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - OperationKind operationKind, CancellationToken cancellationToken) - { - return Task.CompletedTask; - } + /// + public virtual Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, IIdentifiable? rightResourceId, + WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + return Task.FromResult(rightResourceId); + } - /// - public virtual Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - CancellationToken cancellationToken) - { - return Task.CompletedTask; - } + /// + public virtual Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } - /// - public virtual Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - CancellationToken cancellationToken) - { - return Task.CompletedTask; - } + /// + public virtual Task OnAddToRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + { + return Task.CompletedTask; + } - /// - public virtual Task OnWritingAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) - { - return Task.CompletedTask; - } + /// + public virtual Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + { + return Task.CompletedTask; + } - /// - public virtual Task OnWriteSucceededAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) - { - return Task.CompletedTask; - } + /// + public virtual Task OnWritingAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } - /// - public virtual void OnDeserialize(TResource resource) - { - } + /// + public virtual Task OnWriteSucceededAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + return Task.CompletedTask; + } - /// - public virtual void OnSerialize(TResource resource) - { - } + /// + public virtual void OnDeserialize(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 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)>; } diff --git a/src/JsonApiDotNetCore/Resources/OperationContainer.cs b/src/JsonApiDotNetCore/Resources/OperationContainer.cs index da2f64776c..dd2cfe2630 100644 --- a/src/JsonApiDotNetCore/Resources/OperationContainer.cs +++ b/src/JsonApiDotNetCore/Resources/OperationContainer.cs @@ -1,67 +1,61 @@ -using System.Collections.Generic; using JetBrains.Annotations; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Resources +namespace JsonApiDotNetCore.Resources; + +/// +/// Represents a write operation on a JSON:API resource. +/// +[PublicAPI] +public sealed class OperationContainer { - /// - /// Represents a write operation on a JSON:API resource. - /// - [PublicAPI] - public sealed class OperationContainer - { - private static readonly CollectionConverter CollectionConverter = new CollectionConverter(); + public IIdentifiable Resource { get; } + public ITargetedFields TargetedFields { get; } + public IJsonApiRequest Request { get; } - public OperationKind Kind { get; } - public IIdentifiable Resource { get; } - public ITargetedFields TargetedFields { get; } - public IJsonApiRequest Request { get; } + public OperationContainer(IIdentifiable resource, ITargetedFields targetedFields, IJsonApiRequest request) + { + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(targetedFields); + ArgumentNullException.ThrowIfNull(request); - public OperationContainer(OperationKind kind, IIdentifiable resource, ITargetedFields targetedFields, IJsonApiRequest request) - { - ArgumentGuard.NotNull(resource, nameof(resource)); - ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); - ArgumentGuard.NotNull(request, nameof(request)); + Resource = resource; + TargetedFields = targetedFields; + Request = request; + } - Kind = kind; - Resource = resource; - TargetedFields = targetedFields; - Request = request; - } + public void SetTransactionId(string transactionId) + { + ((JsonApiRequest)Request).TransactionId = transactionId; + } - public void SetTransactionId(string transactionId) - { - ((JsonApiRequest)Request).TransactionId = transactionId; - } + public OperationContainer WithResource(IIdentifiable resource) + { + ArgumentNullException.ThrowIfNull(resource); - public OperationContainer WithResource(IIdentifiable resource) - { - ArgumentGuard.NotNull(resource, nameof(resource)); + return new OperationContainer(resource, TargetedFields, Request); + } - return new OperationContainer(Kind, resource, TargetedFields, Request); - } +#pragma warning disable AV1130 // Return type in method signature should be an interface to an unchangeable collection + public ISet GetSecondaryResources() +#pragma warning restore AV1130 // Return type in method signature should be an interface to an unchangeable collection + { + var secondaryResources = new HashSet(IdentifiableComparer.Instance); - public ISet GetSecondaryResources() + foreach (RelationshipAttribute relationship in TargetedFields.Relationships) { - var secondaryResources = new HashSet(IdentifiableComparer.Instance); - - foreach (RelationshipAttribute relationship in TargetedFields.Relationships) - { - AddSecondaryResources(relationship, secondaryResources); - } - - return secondaryResources; + AddSecondaryResources(relationship, secondaryResources); } - private void AddSecondaryResources(RelationshipAttribute relationship, HashSet secondaryResources) - { - object rightValue = relationship.GetValue(Resource); + return secondaryResources; + } - foreach (IIdentifiable rightResource in CollectionConverter.ExtractResources(rightValue)) - { - secondaryResources.Add(rightResource); - } - } + private void AddSecondaryResources(RelationshipAttribute relationship, HashSet secondaryResources) + { + object? rightValue = relationship.GetValue(Resource); + IReadOnlyCollection rightResources = CollectionConverter.Instance.ExtractResources(rightValue); + + secondaryResources.UnionWith(rightResources); } } diff --git a/src/JsonApiDotNetCore/Resources/QueryStringParameterHandlers.cs b/src/JsonApiDotNetCore/Resources/QueryStringParameterHandlers.cs index 1f5d412252..7fe8970b53 100644 --- a/src/JsonApiDotNetCore/Resources/QueryStringParameterHandlers.cs +++ b/src/JsonApiDotNetCore/Resources/QueryStringParameterHandlers.cs @@ -1,15 +1,12 @@ -using System; -using System.Collections.Generic; -using System.Linq; using Microsoft.Extensions.Primitives; -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>> - { - } -} +namespace JsonApiDotNetCore.Resources; + +/// +/// This is an alias type intended to simplify the implementation's method signature. See +/// for usage details. +/// +/// +/// 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 1dfc5a09a7..c17ce60b45 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceChangeTracker.cs @@ -1,103 +1,96 @@ -using System.Collections.Generic; using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Resources.Annotations; -using Newtonsoft.Json; -namespace JsonApiDotNetCore.Resources -{ - /// - [PublicAPI] - public sealed class ResourceChangeTracker : IResourceChangeTracker - where TResource : class, IIdentifiable - { - private readonly IJsonApiOptions _options; - private readonly IResourceContextProvider _resourceContextProvider; - private readonly ITargetedFields _targetedFields; +namespace JsonApiDotNetCore.Resources; - private IDictionary _initiallyStoredAttributeValues; - private IDictionary _requestedAttributeValues; - private IDictionary _finallyStoredAttributeValues; +/// +[PublicAPI] +public sealed class ResourceChangeTracker : IResourceChangeTracker + where TResource : class, IIdentifiable +{ + private readonly IJsonApiRequest _request; + private readonly ITargetedFields _targetedFields; - public ResourceChangeTracker(IJsonApiOptions options, IResourceContextProvider resourceContextProvider, ITargetedFields targetedFields) - { - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider)); - ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); + private Dictionary? _initiallyStoredAttributeValues; + private Dictionary? _requestAttributeValues; + private Dictionary? _finallyStoredAttributeValues; - _options = options; - _resourceContextProvider = resourceContextProvider; - _targetedFields = targetedFields; - } + public ResourceChangeTracker(IJsonApiRequest request, ITargetedFields targetedFields) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(targetedFields); - /// - public void SetInitiallyStoredAttributeValues(TResource resource) - { - ArgumentGuard.NotNull(resource, nameof(resource)); + _request = request; + _targetedFields = targetedFields; + } - ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(); - _initiallyStoredAttributeValues = CreateAttributeDictionary(resource, resourceContext.Attributes); - } + /// + public void SetInitiallyStoredAttributeValues(TResource resource) + { + ArgumentNullException.ThrowIfNull(resource); - /// - public void SetRequestedAttributeValues(TResource resource) - { - ArgumentGuard.NotNull(resource, nameof(resource)); + _initiallyStoredAttributeValues = CreateAttributeDictionary(resource, _request.PrimaryResourceType!.Attributes); + } - _requestedAttributeValues = CreateAttributeDictionary(resource, _targetedFields.Attributes); - } + /// + public void SetRequestAttributeValues(TResource resource) + { + ArgumentNullException.ThrowIfNull(resource); - /// - public void SetFinallyStoredAttributeValues(TResource resource) - { - ArgumentGuard.NotNull(resource, nameof(resource)); + _requestAttributeValues = CreateAttributeDictionary(resource, _targetedFields.Attributes); + } - ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(); - _finallyStoredAttributeValues = CreateAttributeDictionary(resource, resourceContext.Attributes); - } + /// + public void SetFinallyStoredAttributeValues(TResource resource) + { + ArgumentNullException.ThrowIfNull(resource); - private IDictionary CreateAttributeDictionary(TResource resource, IEnumerable attributes) - { - var result = new Dictionary(); + _finallyStoredAttributeValues = CreateAttributeDictionary(resource, _request.PrimaryResourceType!.Attributes); + } - foreach (AttrAttribute attribute in attributes) - { - object value = attribute.GetValue(resource); - string json = JsonConvert.SerializeObject(value, _options.SerializerSettings); - result.Add(attribute.PublicName, json); - } + private Dictionary CreateAttributeDictionary(TResource resource, IEnumerable attributes) + { + var result = new Dictionary(); - return result; + foreach (AttrAttribute attribute in attributes) + { + object? value = attribute.GetValue(resource); + result.Add(attribute.PublicName, value); } - /// - public bool HasImplicitChanges() + return result; + } + + /// + public bool HasImplicitChanges() + { + if (_initiallyStoredAttributeValues != null && _requestAttributeValues != null && _finallyStoredAttributeValues != null) { foreach (string key in _initiallyStoredAttributeValues.Keys) { - if (_requestedAttributeValues.ContainsKey(key)) + if (_requestAttributeValues.TryGetValue(key, out object? requestValue)) { - string requestedValue = _requestedAttributeValues[key]; - string actualValue = _finallyStoredAttributeValues[key]; + object? actualValue = _finallyStoredAttributeValues[key]; - if (requestedValue != 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; } } } - - return false; } + + return false; } } diff --git a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs index b6ed7774ca..a225766fb5 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceDefinitionAccessor.cs @@ -1,213 +1,232 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; +using System.Collections.Immutable; using JetBrains.Annotations; 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 +namespace JsonApiDotNetCore.Resources; + +/// +[PublicAPI] +public class ResourceDefinitionAccessor : IResourceDefinitionAccessor { + private readonly IResourceGraph _resourceGraph; + private readonly IServiceProvider _serviceProvider; + /// - [PublicAPI] - public class ResourceDefinitionAccessor : IResourceDefinitionAccessor + [Obsolete("Use IJsonApiRequest.IsReadOnly.")] + public bool IsReadOnlyRequest { - private readonly IResourceContextProvider _resourceContextProvider; - private readonly IServiceProvider _serviceProvider; - - public ResourceDefinitionAccessor(IResourceContextProvider resourceContextProvider, IServiceProvider serviceProvider) + get { - ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider)); - ArgumentGuard.NotNull(serviceProvider, nameof(serviceProvider)); - - _resourceContextProvider = resourceContextProvider; - _serviceProvider = serviceProvider; + var request = _serviceProvider.GetRequiredService(); + return request.IsReadOnly; } + } - /// - public IReadOnlyCollection OnApplyIncludes(Type resourceType, IReadOnlyCollection existingIncludes) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + /// + [Obsolete("Use injected IQueryableBuilder instead.")] + public IQueryableBuilder QueryableBuilder => _serviceProvider.GetRequiredService(); - dynamic resourceDefinition = ResolveResourceDefinition(resourceType); - return resourceDefinition.OnApplyIncludes(existingIncludes); - } + public ResourceDefinitionAccessor(IResourceGraph resourceGraph, IServiceProvider serviceProvider) + { + ArgumentNullException.ThrowIfNull(resourceGraph); + ArgumentNullException.ThrowIfNull(serviceProvider); - /// - public FilterExpression OnApplyFilter(Type resourceType, FilterExpression existingFilter) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + _resourceGraph = resourceGraph; + _serviceProvider = serviceProvider; + } - dynamic resourceDefinition = ResolveResourceDefinition(resourceType); - return resourceDefinition.OnApplyFilter(existingFilter); - } + /// + public IImmutableSet OnApplyIncludes(ResourceType resourceType, IImmutableSet existingIncludes) + { + ArgumentNullException.ThrowIfNull(resourceType); - /// - public SortExpression OnApplySort(Type resourceType, SortExpression existingSort) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + dynamic resourceDefinition = ResolveResourceDefinition(resourceType); + return resourceDefinition.OnApplyIncludes(existingIncludes); + } - dynamic resourceDefinition = ResolveResourceDefinition(resourceType); - return resourceDefinition.OnApplySort(existingSort); - } + /// + public FilterExpression? OnApplyFilter(ResourceType resourceType, FilterExpression? existingFilter) + { + ArgumentNullException.ThrowIfNull(resourceType); - /// - public PaginationExpression OnApplyPagination(Type resourceType, PaginationExpression existingPagination) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + dynamic resourceDefinition = ResolveResourceDefinition(resourceType); + return resourceDefinition.OnApplyFilter(existingFilter); + } - dynamic resourceDefinition = ResolveResourceDefinition(resourceType); - return resourceDefinition.OnApplyPagination(existingPagination); - } + /// + public SortExpression? OnApplySort(ResourceType resourceType, SortExpression? existingSort) + { + ArgumentNullException.ThrowIfNull(resourceType); - /// - public SparseFieldSetExpression OnApplySparseFieldSet(Type resourceType, SparseFieldSetExpression existingSparseFieldSet) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + dynamic resourceDefinition = ResolveResourceDefinition(resourceType); + return resourceDefinition.OnApplySort(existingSort); + } - dynamic resourceDefinition = ResolveResourceDefinition(resourceType); - return resourceDefinition.OnApplySparseFieldSet(existingSparseFieldSet); - } + /// + public PaginationExpression? OnApplyPagination(ResourceType resourceType, PaginationExpression? existingPagination) + { + ArgumentNullException.ThrowIfNull(resourceType); - /// - public object GetQueryableHandlerForQueryStringParameter(Type resourceType, string parameterName) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - ArgumentGuard.NotNullNorEmpty(parameterName, nameof(parameterName)); + dynamic resourceDefinition = ResolveResourceDefinition(resourceType); + return resourceDefinition.OnApplyPagination(existingPagination); + } - dynamic resourceDefinition = ResolveResourceDefinition(resourceType); - dynamic handlers = resourceDefinition.OnRegisterQueryableHandlersForQueryStringParameters(); + /// + public SparseFieldSetExpression? OnApplySparseFieldSet(ResourceType resourceType, SparseFieldSetExpression? existingSparseFieldSet) + { + ArgumentNullException.ThrowIfNull(resourceType); - return handlers != null && handlers.ContainsKey(parameterName) ? handlers[parameterName] : null; - } + dynamic resourceDefinition = ResolveResourceDefinition(resourceType); + return resourceDefinition.OnApplySparseFieldSet(existingSparseFieldSet); + } - /// - public IDictionary GetMeta(Type resourceType, IIdentifiable resourceInstance) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + /// + public object? GetQueryableHandlerForQueryStringParameter(Type resourceClrType, string parameterName) + { + ArgumentNullException.ThrowIfNull(resourceClrType); + ArgumentException.ThrowIfNullOrEmpty(parameterName); - dynamic resourceDefinition = ResolveResourceDefinition(resourceType); - return resourceDefinition.GetMeta((dynamic)resourceInstance); - } + dynamic resourceDefinition = ResolveResourceDefinition(resourceClrType); + dynamic? handlers = resourceDefinition.OnRegisterQueryableHandlersForQueryStringParameters(); - /// - public async Task OnPrepareWriteAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) - where TResource : class, IIdentifiable + if (handlers != null) { - ArgumentGuard.NotNull(resource, nameof(resource)); - - dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); - await resourceDefinition.OnPrepareWriteAsync(resource, operationKind, cancellationToken); + if (handlers.ContainsKey(parameterName)) + { + return handlers[parameterName]; + } } - /// - public async Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, - IIdentifiable rightResourceId, OperationKind operationKind, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - ArgumentGuard.NotNull(leftResource, nameof(leftResource)); - ArgumentGuard.NotNull(hasOneRelationship, nameof(hasOneRelationship)); + return null; + } - dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); - return await resourceDefinition.OnSetToOneRelationshipAsync(leftResource, hasOneRelationship, rightResourceId, operationKind, cancellationToken); - } + /// + public IDictionary? GetMeta(ResourceType resourceType, IIdentifiable resourceInstance) + { + ArgumentNullException.ThrowIfNull(resourceType); - /// - public async Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, - ISet rightResourceIds, OperationKind operationKind, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - ArgumentGuard.NotNull(leftResource, nameof(leftResource)); - ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship)); - ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); + dynamic resourceDefinition = ResolveResourceDefinition(resourceType); + return resourceDefinition.GetMeta((dynamic)resourceInstance); + } - dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); - await resourceDefinition.OnSetToManyRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, operationKind, cancellationToken); - } + /// + public async Task OnPrepareWriteAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentNullException.ThrowIfNull(resource); - /// - public async Task OnAddToRelationshipAsync(TId leftResourceId, HasManyAttribute hasManyRelationship, - ISet rightResourceIds, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship)); - ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); + dynamic resourceDefinition = ResolveResourceDefinition(resource.GetClrType()); + await resourceDefinition.OnPrepareWriteAsync((dynamic)resource, writeOperation, cancellationToken); + } - dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); - await resourceDefinition.OnAddToRelationshipAsync(leftResourceId, hasManyRelationship, rightResourceIds, cancellationToken); - } + /// + public async Task OnSetToOneRelationshipAsync(TResource leftResource, HasOneAttribute hasOneRelationship, + IIdentifiable? rightResourceId, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentNullException.ThrowIfNull(leftResource); + ArgumentNullException.ThrowIfNull(hasOneRelationship); - /// - public async Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, - ISet rightResourceIds, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - ArgumentGuard.NotNull(leftResource, nameof(leftResource)); - ArgumentGuard.NotNull(hasManyRelationship, nameof(hasManyRelationship)); - ArgumentGuard.NotNull(rightResourceIds, nameof(rightResourceIds)); + dynamic resourceDefinition = ResolveResourceDefinition(leftResource.GetClrType()); - dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); - await resourceDefinition.OnRemoveFromRelationshipAsync(leftResource, hasManyRelationship, rightResourceIds, cancellationToken); - } + return await resourceDefinition.OnSetToOneRelationshipAsync((dynamic)leftResource, hasOneRelationship, rightResourceId, writeOperation, + cancellationToken); + } - /// - public async Task OnWritingAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - ArgumentGuard.NotNull(resource, nameof(resource)); + /// + public async Task OnSetToManyRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, + ISet rightResourceIds, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentNullException.ThrowIfNull(leftResource); + ArgumentNullException.ThrowIfNull(hasManyRelationship); + ArgumentNullException.ThrowIfNull(rightResourceIds); - dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); - await resourceDefinition.OnWritingAsync(resource, operationKind, cancellationToken); - } + dynamic resourceDefinition = ResolveResourceDefinition(leftResource.GetClrType()); + await resourceDefinition.OnSetToManyRelationshipAsync((dynamic)leftResource, hasManyRelationship, rightResourceIds, writeOperation, cancellationToken); + } - /// - public async Task OnWriteSucceededAsync(TResource resource, OperationKind operationKind, CancellationToken cancellationToken) - where TResource : class, IIdentifiable - { - ArgumentGuard.NotNull(resource, nameof(resource)); + /// + public async Task OnAddToRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, ISet rightResourceIds, + CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentNullException.ThrowIfNull(hasManyRelationship); + ArgumentNullException.ThrowIfNull(rightResourceIds); - dynamic resourceDefinition = ResolveResourceDefinition(typeof(TResource)); - await resourceDefinition.OnWriteSucceededAsync(resource, operationKind, cancellationToken); - } + dynamic resourceDefinition = ResolveResourceDefinition(leftResource.GetClrType()); + await resourceDefinition.OnAddToRelationshipAsync((dynamic)leftResource, hasManyRelationship, rightResourceIds, cancellationToken); + } - /// - public void OnDeserialize(IIdentifiable resource) - { - ArgumentGuard.NotNull(resource, nameof(resource)); + /// + public async Task OnRemoveFromRelationshipAsync(TResource leftResource, HasManyAttribute hasManyRelationship, + ISet rightResourceIds, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentNullException.ThrowIfNull(leftResource); + ArgumentNullException.ThrowIfNull(hasManyRelationship); + ArgumentNullException.ThrowIfNull(rightResourceIds); - dynamic resourceDefinition = ResolveResourceDefinition(resource.GetType()); - resourceDefinition.OnDeserialize((dynamic)resource); - } + dynamic resourceDefinition = ResolveResourceDefinition(leftResource.GetClrType()); + await resourceDefinition.OnRemoveFromRelationshipAsync((dynamic)leftResource, hasManyRelationship, rightResourceIds, cancellationToken); + } - /// - public void OnSerialize(IIdentifiable resource) - { - ArgumentGuard.NotNull(resource, nameof(resource)); + /// + public async Task OnWritingAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentNullException.ThrowIfNull(resource); - dynamic resourceDefinition = ResolveResourceDefinition(resource.GetType()); - resourceDefinition.OnSerialize((dynamic)resource); - } + dynamic resourceDefinition = ResolveResourceDefinition(resource.GetClrType()); + await resourceDefinition.OnWritingAsync((dynamic)resource, writeOperation, cancellationToken); + } - protected virtual object ResolveResourceDefinition(Type resourceType) - { - ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(resourceType); + /// + public async Task OnWriteSucceededAsync(TResource resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + where TResource : class, IIdentifiable + { + ArgumentNullException.ThrowIfNull(resource); - if (resourceContext.IdentityType == typeof(int)) - { - Type intResourceDefinitionType = typeof(IResourceDefinition<>).MakeGenericType(resourceContext.ResourceType); - object intResourceDefinition = _serviceProvider.GetService(intResourceDefinitionType); + dynamic resourceDefinition = ResolveResourceDefinition(resource.GetClrType()); + await resourceDefinition.OnWriteSucceededAsync((dynamic)resource, writeOperation, cancellationToken); + } - if (intResourceDefinition != null) - { - return intResourceDefinition; - } - } + /// + public void OnDeserialize(IIdentifiable resource) + { + ArgumentNullException.ThrowIfNull(resource); - Type resourceDefinitionType = typeof(IResourceDefinition<,>).MakeGenericType(resourceContext.ResourceType, resourceContext.IdentityType); - return _serviceProvider.GetRequiredService(resourceDefinitionType); - } + dynamic resourceDefinition = ResolveResourceDefinition(resource.GetClrType()); + resourceDefinition.OnDeserialize((dynamic)resource); + } + + /// + public void OnSerialize(IIdentifiable resource) + { + ArgumentNullException.ThrowIfNull(resource); + + dynamic resourceDefinition = ResolveResourceDefinition(resource.GetClrType()); + resourceDefinition.OnSerialize((dynamic)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 721022d6f5..5a18763939 100644 --- a/src/JsonApiDotNetCore/Resources/ResourceFactory.cs +++ b/src/JsonApiDotNetCore/Resources/ResourceFactory.cs @@ -1,148 +1,153 @@ -using System; -using System.Collections.Generic; -using System.Linq; using System.Linq.Expressions; using System.Reflection; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; using Microsoft.Extensions.DependencyInjection; -namespace JsonApiDotNetCore.Resources +namespace JsonApiDotNetCore.Resources; + +/// +internal sealed class ResourceFactory : IResourceFactory { + private static readonly TypeLocator TypeLocator = new(); + + private readonly IServiceProvider _serviceProvider; + + public ResourceFactory(IServiceProvider serviceProvider) + { + ArgumentNullException.ThrowIfNull(serviceProvider); + + _serviceProvider = serviceProvider; + } + /// - internal sealed class ResourceFactory : IResourceFactory + public IIdentifiable CreateInstance(Type resourceClrType) { - private readonly IServiceProvider _serviceProvider; + ArgumentNullException.ThrowIfNull(resourceClrType); - public ResourceFactory(IServiceProvider serviceProvider) + if (!resourceClrType.IsAssignableTo(typeof(IIdentifiable))) { - ArgumentGuard.NotNull(serviceProvider, nameof(serviceProvider)); - - _serviceProvider = serviceProvider; + throw new InvalidOperationException($"Resource type '{resourceClrType}' does not implement IIdentifiable."); } - /// - public IResourceDefinitionAccessor GetResourceDefinitionAccessor() + if (resourceClrType.IsAbstract) { - return _serviceProvider.GetRequiredService(); + return CreateWrapperForAbstractType(resourceClrType); } - /// - public IIdentifiable CreateInstance(Type resourceType) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + return InnerCreateInstance(resourceClrType, _serviceProvider); + } - return InnerCreateInstance(resourceType, _serviceProvider); - } + private static IIdentifiable CreateWrapperForAbstractType(Type resourceClrType) + { + ResourceDescriptor? descriptor = TypeLocator.ResolveResourceDescriptor(resourceClrType); - /// - public TResource CreateInstance() - where TResource : IIdentifiable + if (descriptor == null) { - return (TResource)InnerCreateInstance(typeof(TResource), _serviceProvider); + throw new InvalidOperationException($"Resource type '{resourceClrType}' implements 'IIdentifiable', but not 'IIdentifiable'."); } - private static IIdentifiable InnerCreateInstance(Type type, IServiceProvider serviceProvider) - { - bool hasSingleConstructorWithoutParameters = HasSingleConstructorWithoutParameters(type); + Type wrapperClrType = typeof(AbstractResourceWrapper<>).MakeGenericType(descriptor.IdClrType); + ConstructorInfo constructor = wrapperClrType.GetConstructors().Single(); - try - { - return hasSingleConstructorWithoutParameters - ? (IIdentifiable)Activator.CreateInstance(type) - : (IIdentifiable)ActivatorUtilities.CreateInstance(serviceProvider, type); - } + object resource = constructor.Invoke([resourceClrType]); + return (IIdentifiable)resource; + } + + /// + public TResource CreateInstance() + where TResource : IIdentifiable + { + return (TResource)InnerCreateInstance(typeof(TResource), _serviceProvider); + } + + private static IIdentifiable InnerCreateInstance(Type type, IServiceProvider serviceProvider) + { + bool hasSingleConstructorWithoutParameters = HasSingleConstructorWithoutParameters(type); + + try + { + return hasSingleConstructorWithoutParameters + ? (IIdentifiable)Activator.CreateInstance(type)! + : (IIdentifiable)ActivatorUtilities.CreateInstance(serviceProvider, type); + } #pragma warning disable AV1210 // Catch a specific exception instead of Exception, SystemException or ApplicationException - catch (Exception exception) + catch (Exception exception) #pragma warning restore AV1210 // Catch a specific exception instead of Exception, SystemException or ApplicationException - { - throw new InvalidOperationException( - hasSingleConstructorWithoutParameters - ? $"Failed to create an instance of '{type.FullName}' using its default constructor." - : $"Failed to create an instance of '{type.FullName}' using injected constructor parameters.", exception); - } + { + throw new InvalidOperationException( + hasSingleConstructorWithoutParameters + ? $"Failed to create an instance of '{type.FullName}' using its default constructor." + : $"Failed to create an instance of '{type.FullName}' using injected constructor parameters.", exception); } + } - /// - public NewExpression CreateNewExpression(Type resourceType) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + /// + public NewExpression CreateNewExpression(Type resourceClrType) + { + ArgumentNullException.ThrowIfNull(resourceClrType); - if (HasSingleConstructorWithoutParameters(resourceType)) - { - return Expression.New(resourceType); - } + if (HasSingleConstructorWithoutParameters(resourceClrType)) + { + return Expression.New(resourceClrType); + } - var constructorArguments = new List(); + List constructorArguments = []; - ConstructorInfo longestConstructor = GetLongestConstructor(resourceType); + ConstructorInfo longestConstructor = GetLongestConstructor(resourceClrType); - foreach (ParameterInfo constructorParameter in longestConstructor.GetParameters()) + foreach (ParameterInfo constructorParameter in longestConstructor.GetParameters()) + { + try { - try - { - object constructorArgument = ActivatorUtilities.GetServiceOrCreateInstance(_serviceProvider, constructorParameter.ParameterType); + object constructorArgument = ActivatorUtilities.GetServiceOrCreateInstance(_serviceProvider, constructorParameter.ParameterType); - Expression argumentExpression = CreateTupleAccessExpressionForConstant(constructorArgument, constructorArgument.GetType()); - - constructorArguments.Add(argumentExpression); - } + Expression argumentExpression = SystemExpressionBuilder.CloseOver(constructorArgument); + constructorArguments.Add(argumentExpression); + } #pragma warning disable AV1210 // Catch a specific exception instead of Exception, SystemException or ApplicationException - catch (Exception exception) + catch (Exception exception) #pragma warning restore AV1210 // Catch a specific exception instead of Exception, SystemException or ApplicationException - { - throw new InvalidOperationException( - $"Failed to create an instance of '{resourceType.FullName}': Parameter '{constructorParameter.Name}' could not be resolved.", - exception); - } + { + throw new InvalidOperationException( + $"Failed to create an instance of '{resourceClrType.FullName}': Parameter '{constructorParameter.Name}' could not be resolved.", exception); } - - return Expression.New(longestConstructor, constructorArguments); } - private static Expression CreateTupleAccessExpressionForConstant(object value, Type type) - { - MethodInfo tupleCreateMethod = typeof(Tuple).GetMethods() - .Single(method => method.Name == "Create" && method.IsGenericMethod && method.GetGenericArguments().Length == 1); + return Expression.New(longestConstructor, constructorArguments); + } - MethodInfo constructedTupleCreateMethod = tupleCreateMethod.MakeGenericMethod(type); + private static bool HasSingleConstructorWithoutParameters(Type type) + { + ConstructorInfo[] constructors = type.GetConstructors().Where(constructor => !constructor.IsStatic).ToArray(); - ConstantExpression constantExpression = Expression.Constant(value, type); + return constructors.Length == 1 && constructors[0].GetParameters().Length == 0; + } - MethodCallExpression tupleCreateCall = Expression.Call(constructedTupleCreateMethod, constantExpression); - return Expression.Property(tupleCreateCall, "Item1"); - } + private static ConstructorInfo GetLongestConstructor(Type type) + { + ConstructorInfo[] constructors = type.GetConstructors().Where(constructor => !constructor.IsStatic).ToArray(); - internal static bool HasSingleConstructorWithoutParameters(Type type) + if (constructors.Length == 0) { - ConstructorInfo[] constructors = type.GetConstructors().Where(constructor => !constructor.IsStatic).ToArray(); - - return constructors.Length == 1 && constructors[0].GetParameters().Length == 0; + throw new InvalidOperationException($"No public constructor was found for '{type.FullName}'."); } - private static ConstructorInfo GetLongestConstructor(Type type) - { - ConstructorInfo[] constructors = type.GetConstructors().Where(constructor => !constructor.IsStatic).ToArray(); - - if (constructors.Length == 0) - { - throw new InvalidOperationException($"No public constructor was found for '{type.FullName}'."); - } + ConstructorInfo bestMatch = constructors[0]; + int maxParameterLength = constructors[0].GetParameters().Length; - ConstructorInfo bestMatch = constructors[0]; - int maxParameterLength = constructors[0].GetParameters().Length; + for (int index = 1; index < constructors.Length; index++) + { + ConstructorInfo constructor = constructors[index]; + int length = constructor.GetParameters().Length; - for (int index = 1; index < constructors.Length; index++) + if (length > maxParameterLength) { - ConstructorInfo constructor = constructors[index]; - int length = constructor.GetParameters().Length; - - if (length > maxParameterLength) - { - bestMatch = constructor; - maxParameterLength = length; - } + bestMatch = constructor; + maxParameterLength = length; } - - return bestMatch; } + + return bestMatch; } } diff --git a/src/JsonApiDotNetCore/Resources/ResourceHooksDefinition.cs b/src/JsonApiDotNetCore/Resources/ResourceHooksDefinition.cs deleted file mode 100644 index 60b9e67505..0000000000 --- a/src/JsonApiDotNetCore/Resources/ResourceHooksDefinition.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Hooks.Internal; -using JsonApiDotNetCore.Hooks.Internal.Execution; - -namespace JsonApiDotNetCore.Resources -{ - /// - /// Provides a resource-specific extensibility point for API developers to be notified of various events and influence behavior using custom code. It is - /// intended to improve the developer experience and reduce boilerplate for commonly required features. The goal of this class is to reduce the frequency - /// with which developers have to override the service and repository layers. - /// - /// - /// The resource type. - /// - [PublicAPI] - public class ResourceHooksDefinition : IResourceHookContainer - where TResource : class, IIdentifiable - { - protected IResourceGraph ResourceGraph { get; } - - public ResourceHooksDefinition(IResourceGraph resourceGraph) - { - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - - ResourceGraph = resourceGraph; - } - - /// - /// - /// This method is part of Resource Hooks, which is an experimental feature and subject to change in future versions. - /// - public virtual void AfterCreate(HashSet resources, ResourcePipeline pipeline) - { - } - - /// - /// - /// This method is part of Resource Hooks, which is an experimental feature and subject to change in future versions. - /// - public virtual void AfterRead(HashSet resources, ResourcePipeline pipeline, bool isIncluded = false) - { - } - - /// - /// - /// This method is part of Resource Hooks, which is an experimental feature and subject to change in future versions. - /// - public virtual void AfterUpdate(HashSet resources, ResourcePipeline pipeline) - { - } - - /// - /// - /// This method is part of Resource Hooks, which is an experimental feature and subject to change in future versions. - /// - public virtual void AfterDelete(HashSet resources, ResourcePipeline pipeline, bool succeeded) - { - } - - /// - /// - /// This method is part of Resource Hooks, which is an experimental feature and subject to change in future versions. - /// - public virtual void AfterUpdateRelationship(IRelationshipsDictionary resourcesByRelationship, ResourcePipeline pipeline) - { - } - - /// - /// - /// This method is part of Resource Hooks, which is an experimental feature and subject to change in future versions. - /// - public virtual IEnumerable BeforeCreate(IResourceHashSet resources, ResourcePipeline pipeline) - { - return resources; - } - - /// - /// - /// This method is part of Resource Hooks, which is an experimental feature and subject to change in future versions. - /// - public virtual void BeforeRead(ResourcePipeline pipeline, bool isIncluded = false, string stringId = null) - { - } - - /// - /// - /// This method is part of Resource Hooks, which is an experimental feature and subject to change in future versions. - /// - public virtual IEnumerable BeforeUpdate(IDiffableResourceHashSet resources, ResourcePipeline pipeline) - { - return resources; - } - - /// - /// - /// This method is part of Resource Hooks, which is an experimental feature and subject to change in future versions. - /// - public virtual IEnumerable BeforeDelete(IResourceHashSet resources, ResourcePipeline pipeline) - { - return resources; - } - - /// - /// - /// This method is part of Resource Hooks, which is an experimental feature and subject to change in future versions. - /// - public virtual IEnumerable BeforeUpdateRelationship(HashSet ids, IRelationshipsDictionary resourcesByRelationship, - ResourcePipeline pipeline) - { - return ids; - } - - /// - /// - /// This method is part of Resource Hooks, which is an experimental feature and subject to change in future versions. - /// - public virtual void BeforeImplicitUpdateRelationship(IRelationshipsDictionary resourcesByRelationship, ResourcePipeline pipeline) - { - } - - /// - /// - /// This method is part of Resource Hooks, which is an experimental feature and subject to change in future versions. - /// - public virtual IEnumerable OnReturn(HashSet resources, ResourcePipeline pipeline) - { - return resources; - } - } -} diff --git a/src/JsonApiDotNetCore/Resources/SortExpressionLambdaConverter.cs b/src/JsonApiDotNetCore/Resources/SortExpressionLambdaConverter.cs new file mode 100644 index 0000000000..97e014f344 --- /dev/null +++ b/src/JsonApiDotNetCore/Resources/SortExpressionLambdaConverter.cs @@ -0,0 +1,167 @@ +using System.Collections.Immutable; +using System.ComponentModel; +using System.Linq.Expressions; +using System.Reflection; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Resources; + +internal sealed class SortExpressionLambdaConverter +{ + private readonly IResourceGraph _resourceGraph; + private readonly List _fields = []; + + public SortExpressionLambdaConverter(IResourceGraph resourceGraph) + { + ArgumentNullException.ThrowIfNull(resourceGraph); + + _resourceGraph = resourceGraph; + } + + public SortElementExpression FromLambda(Expression> keySelector, ListSortDirection sortDirection) + { + ArgumentNullException.ThrowIfNull(keySelector); + + _fields.Clear(); + + Expression lambdaBodyExpression = SkipConvert(keySelector.Body); + (Expression? expression, bool isCount) = TryReadCount(lambdaBodyExpression); + + if (expression != null) + { + expression = SkipConvert(expression); + expression = isCount ? ReadToManyRelationship(expression) : ReadAttribute(expression); + + while (expression != null) + { + expression = SkipConvert(expression); + + if (IsLambdaParameter(expression, keySelector.Parameters[0])) + { + return ToSortElement(isCount, sortDirection); + } + + expression = ReadToOneRelationship(expression); + } + } + + throw new InvalidOperationException($"Unsupported expression body '{lambdaBodyExpression}'."); + } + + private static Expression SkipConvert(Expression expression) + { + Expression inner = expression; + + while (true) + { + if (inner is UnaryExpression { NodeType: ExpressionType.Convert or ExpressionType.TypeAs } unary) + { + inner = unary.Operand; + } + else + { + return inner; + } + } + } + + private static (Expression? innerExpression, bool isCount) TryReadCount(Expression expression) + { + if (expression is MethodCallExpression { Method.Name: "Count" } methodCallExpression) + { + if (methodCallExpression.Arguments.Count <= 1) + { + return (methodCallExpression.Arguments[0], true); + } + + throw new InvalidOperationException("Count method that takes a predicate is not supported."); + } + + if (expression is MemberExpression memberExpression) + { + if (memberExpression.Member is { MemberType: MemberTypes.Property, Name: "Count" or "Length" }) + { + if (memberExpression.Member.GetCustomAttribute() == null) + { + return (memberExpression.Expression, true); + } + } + + return (memberExpression, false); + } + + return (null, false); + } + + private Expression? ReadToManyRelationship(Expression expression) + { + if (expression is MemberExpression memberExpression) + { + ResourceType resourceType = _resourceGraph.GetResourceType(memberExpression.Member.DeclaringType!); + RelationshipAttribute? relationship = resourceType.FindRelationshipByPropertyName(memberExpression.Member.Name); + + if (relationship is HasManyAttribute) + { + _fields.Insert(0, relationship); + return memberExpression.Expression; + } + } + + throw new InvalidOperationException($"Expected property for JSON:API to-many relationship, but found '{expression}'."); + } + + private Expression? ReadAttribute(Expression expression) + { + if (expression is MemberExpression { Expression: not null } memberExpression) + { + ResourceType resourceType = _resourceGraph.GetResourceType(memberExpression.Expression.Type); + AttrAttribute? attribute = resourceType.FindAttributeByPropertyName(memberExpression.Member.Name); + + if (attribute != null) + { + _fields.Insert(0, attribute); + return memberExpression.Expression; + } + } + + throw new InvalidOperationException($"Expected property for JSON:API attribute, but found '{expression}'."); + } + + private Expression? ReadToOneRelationship(Expression expression) + { + if (expression is MemberExpression memberExpression) + { + ResourceType resourceType = _resourceGraph.GetResourceType(memberExpression.Member.DeclaringType!); + RelationshipAttribute? relationship = resourceType.FindRelationshipByPropertyName(memberExpression.Member.Name); + + if (relationship is HasOneAttribute) + { + _fields.Insert(0, relationship); + return memberExpression.Expression; + } + } + + throw new InvalidOperationException($"Expected property for JSON:API to-one relationship, but found '{expression}'."); + } + + private static bool IsLambdaParameter(Expression expression, ParameterExpression lambdaParameter) + { + return expression is ParameterExpression parameterExpression && parameterExpression.Name == lambdaParameter.Name; + } + + private SortElementExpression ToSortElement(bool isCount, ListSortDirection sortDirection) + { + var chain = new ResourceFieldChainExpression(_fields.ToImmutableArray()); + bool isAscending = sortDirection == ListSortDirection.Ascending; + + if (isCount) + { + var countExpression = new CountExpression(chain); + return new SortElementExpression(countExpression, isAscending); + } + + return new SortElementExpression(chain, isAscending); + } +} diff --git a/src/JsonApiDotNetCore/Resources/TargetedFields.cs b/src/JsonApiDotNetCore/Resources/TargetedFields.cs index 46cd2fed6a..420058106f 100644 --- a/src/JsonApiDotNetCore/Resources/TargetedFields.cs +++ b/src/JsonApiDotNetCore/Resources/TargetedFields.cs @@ -1,15 +1,32 @@ -using System.Collections.Generic; +using JetBrains.Annotations; using JsonApiDotNetCore.Resources.Annotations; -namespace JsonApiDotNetCore.Resources +namespace JsonApiDotNetCore.Resources; + +/// +[PublicAPI] +public sealed class TargetedFields : ITargetedFields { + IReadOnlySet ITargetedFields.Attributes => Attributes.AsReadOnly(); + IReadOnlySet ITargetedFields.Relationships => Relationships.AsReadOnly(); + + public HashSet Attributes { get; } = []; + public HashSet Relationships { get; } = []; + /// - public sealed class TargetedFields : ITargetedFields + public void CopyFrom(ITargetedFields other) { - /// - public ISet Attributes { get; set; } = new HashSet(); + ArgumentNullException.ThrowIfNull(other); + + Clear(); - /// - public ISet Relationships { get; set; } = new HashSet(); + Attributes.UnionWith(other.Attributes); + Relationships.UnionWith(other.Relationships); + } + + public void Clear() + { + Attributes.Clear(); + Relationships.Clear(); } } diff --git a/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs deleted file mode 100644 index 9f22dcbe3f..0000000000 --- a/src/JsonApiDotNetCore/Serialization/AtomicOperationsResponseSerializer.cs +++ /dev/null @@ -1,131 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Queries.Internal; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization.Building; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Serialization -{ - /// - /// Server serializer implementation of for atomic:operations responses. - /// - [PublicAPI] - public sealed class AtomicOperationsResponseSerializer : BaseSerializer, IJsonApiSerializer - { - private readonly IMetaBuilder _metaBuilder; - private readonly ILinkBuilder _linkBuilder; - private readonly IFieldsToSerialize _fieldsToSerialize; - private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; - private readonly IEvaluatedIncludeCache _evaluatedIncludeCache; - private readonly IJsonApiRequest _request; - private readonly IJsonApiOptions _options; - - /// - public string ContentType { get; } = HeaderConstants.AtomicOperationsMediaType; - - public AtomicOperationsResponseSerializer(IResourceObjectBuilder resourceObjectBuilder, IMetaBuilder metaBuilder, ILinkBuilder linkBuilder, - IFieldsToSerialize fieldsToSerialize, IResourceDefinitionAccessor resourceDefinitionAccessor, IEvaluatedIncludeCache evaluatedIncludeCache, - IJsonApiRequest request, IJsonApiOptions options) - : base(resourceObjectBuilder) - { - ArgumentGuard.NotNull(metaBuilder, nameof(metaBuilder)); - ArgumentGuard.NotNull(linkBuilder, nameof(linkBuilder)); - ArgumentGuard.NotNull(fieldsToSerialize, nameof(fieldsToSerialize)); - ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); - ArgumentGuard.NotNull(evaluatedIncludeCache, nameof(evaluatedIncludeCache)); - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(options, nameof(options)); - - _metaBuilder = metaBuilder; - _linkBuilder = linkBuilder; - _fieldsToSerialize = fieldsToSerialize; - _resourceDefinitionAccessor = resourceDefinitionAccessor; - _evaluatedIncludeCache = evaluatedIncludeCache; - _request = request; - _options = options; - } - - /// - public string Serialize(object content) - { - if (content is IList operations) - { - return SerializeOperationsDocument(operations); - } - - if (content is ErrorDocument errorDocument) - { - return SerializeErrorDocument(errorDocument); - } - - throw new InvalidOperationException("Data being returned must be errors or operations."); - } - - private string SerializeOperationsDocument(IEnumerable operations) - { - var document = new AtomicOperationsDocument - { - Results = operations.Select(SerializeOperation).ToList(), - Meta = _metaBuilder.Build() - }; - - if (_options.IncludeJsonApiVersion) - { - document.JsonApi = new JsonApiObject - { - Version = "1.1", - Ext = new List - { - "https://jsonapi.org/ext/atomic" - } - }; - } - - return SerializeObject(document, _options.SerializerSettings); - } - - private AtomicResultObject SerializeOperation(OperationContainer operation) - { - ResourceObject resourceObject = null; - - if (operation != null) - { - _request.CopyFrom(operation.Request); - _fieldsToSerialize.ResetCache(); - _evaluatedIncludeCache.Set(null); - - _resourceDefinitionAccessor.OnSerialize(operation.Resource); - - Type resourceType = operation.Resource.GetType(); - IReadOnlyCollection attributes = _fieldsToSerialize.GetAttributes(resourceType); - IReadOnlyCollection relationships = _fieldsToSerialize.GetRelationships(resourceType); - - resourceObject = ResourceObjectBuilder.Build(operation.Resource, attributes, relationships); - } - - if (resourceObject != null) - { - resourceObject.Links = _linkBuilder.GetResourceLinks(resourceObject.Type, resourceObject.Id); - } - - return new AtomicResultObject - { - Data = resourceObject - }; - } - - private string SerializeErrorDocument(ErrorDocument errorDocument) - { - return SerializeObject(errorDocument, _options.SerializerSettings, serializer => - { - serializer.ApplyErrorSettings(); - }); - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs deleted file mode 100644 index 6d7bccfe47..0000000000 --- a/src/JsonApiDotNetCore/Serialization/BaseDeserializer.cs +++ /dev/null @@ -1,361 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Resources.Internal; -using JsonApiDotNetCore.Serialization.Client.Internal; -using JsonApiDotNetCore.Serialization.Objects; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -namespace JsonApiDotNetCore.Serialization -{ - /// - /// Abstract base class for deserialization. Deserializes JSON content into s and constructs instances of the resource(s) - /// in the document body. - /// - [PublicAPI] - public abstract class BaseDeserializer - { - private protected static readonly CollectionConverter CollectionConverter = new CollectionConverter(); - - protected IResourceContextProvider ResourceContextProvider { get; } - protected IResourceFactory ResourceFactory { get; } - protected Document Document { get; set; } - - protected int? AtomicOperationIndex { get; set; } - - protected BaseDeserializer(IResourceContextProvider resourceContextProvider, IResourceFactory resourceFactory) - { - ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider)); - ArgumentGuard.NotNull(resourceFactory, nameof(resourceFactory)); - - ResourceContextProvider = resourceContextProvider; - ResourceFactory = resourceFactory; - } - - /// - /// This method is called each time a is constructed from the serialized content, which is used to do additional processing - /// depending on the type of deserializer. - /// - /// - /// See the implementation of this method in and for examples. - /// - /// - /// The resource that was constructed from the document's body. - /// - /// - /// The metadata for the exposed field. - /// - /// - /// Relationship data for . Is null when is not a . - /// - protected abstract void AfterProcessField(IIdentifiable resource, ResourceFieldAttribute field, RelationshipEntry data = null); - - protected object DeserializeBody(string body) - { - ArgumentGuard.NotNullNorEmpty(body, nameof(body)); - - JToken bodyJToken = LoadJToken(body); - Document = bodyJToken.ToObject(); - - if (Document != null) - { - if (Document.IsManyData) - { - return Document.ManyData.Select(ParseResourceObject).ToHashSet(IdentifiableComparer.Instance); - } - - if (Document.SingleData != null) - { - return ParseResourceObject(Document.SingleData); - } - } - - return null; - } - - /// - /// Sets the attributes on a parsed resource. - /// - /// - /// The parsed resource. - /// - /// - /// Attributes and their values, as in the serialized content. - /// - /// - /// Exposed attributes for . - /// - protected IIdentifiable SetAttributes(IIdentifiable resource, IDictionary attributeValues, - IReadOnlyCollection attributes) - { - ArgumentGuard.NotNull(resource, nameof(resource)); - ArgumentGuard.NotNull(attributes, nameof(attributes)); - - if (attributeValues.IsNullOrEmpty()) - { - return resource; - } - - foreach (AttrAttribute attr in attributes) - { - if (attributeValues.TryGetValue(attr.PublicName, out object newValue)) - { - if (attr.Property.SetMethod == null) - { - throw new JsonApiSerializationException("Attribute is read-only.", $"Attribute '{attr.PublicName}' is read-only.", - atomicOperationIndex: AtomicOperationIndex); - } - - object convertedValue = ConvertAttrValue(newValue, attr.Property.PropertyType); - attr.SetValue(resource, convertedValue); - AfterProcessField(resource, attr); - } - } - - return resource; - } - - /// - /// Sets the relationships on a parsed resource. - /// - /// - /// The parsed resource. - /// - /// - /// Relationships and their values, as in the serialized content. - /// - /// - /// Exposed relationships for . - /// - protected virtual IIdentifiable SetRelationships(IIdentifiable resource, IDictionary relationshipValues, - IReadOnlyCollection relationshipAttributes) - { - ArgumentGuard.NotNull(resource, nameof(resource)); - ArgumentGuard.NotNull(relationshipAttributes, nameof(relationshipAttributes)); - - if (relationshipValues.IsNullOrEmpty()) - { - return resource; - } - - foreach (RelationshipAttribute attr in relationshipAttributes) - { - bool relationshipIsProvided = relationshipValues.TryGetValue(attr.PublicName, out RelationshipEntry relationshipData); - - if (!relationshipIsProvided || !relationshipData.IsPopulated) - { - continue; - } - - if (attr is HasOneAttribute hasOneAttribute) - { - SetHasOneRelationship(resource, hasOneAttribute, relationshipData); - } - else if (attr is HasManyAttribute hasManyAttribute) - { - SetHasManyRelationship(resource, hasManyAttribute, relationshipData); - } - } - - return resource; - } - -#pragma warning disable AV1130 // Return type in method signature should be a collection interface instead of a concrete type - protected JToken LoadJToken(string body) -#pragma warning restore AV1130 // Return type in method signature should be a collection interface instead of a concrete type - { - using JsonReader jsonReader = new JsonTextReader(new StringReader(body)) - { - // https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/509 - DateParseHandling = DateParseHandling.None - }; - - return JToken.Load(jsonReader); - } - - /// - /// Creates an instance of the referenced type in and sets its attributes and relationships. - /// - /// - /// The parsed resource. - /// - protected IIdentifiable ParseResourceObject(ResourceObject data) - { - AssertHasType(data, null); - - if (AtomicOperationIndex == null) - { - AssertHasNoLid(data); - } - - ResourceContext resourceContext = GetExistingResourceContext(data.Type); - IIdentifiable resource = ResourceFactory.CreateInstance(resourceContext.ResourceType); - - resource = SetAttributes(resource, data.Attributes, resourceContext.Attributes); - resource = SetRelationships(resource, data.Relationships, resourceContext.Relationships); - - if (data.Id != null) - { - resource.StringId = data.Id; - } - - resource.LocalId = data.Lid; - - return resource; - } - - protected ResourceContext GetExistingResourceContext(string publicName) - { - ResourceContext resourceContext = ResourceContextProvider.GetResourceContext(publicName); - - if (resourceContext == null) - { - throw new JsonApiSerializationException("Request body includes unknown resource type.", $"Resource type '{publicName}' does not exist.", - atomicOperationIndex: AtomicOperationIndex); - } - - return resourceContext; - } - - /// - /// Sets a HasOne relationship on a parsed resource. - /// - private void SetHasOneRelationship(IIdentifiable resource, HasOneAttribute hasOneRelationship, RelationshipEntry relationshipData) - { - if (relationshipData.ManyData != null) - { - throw new JsonApiSerializationException("Expected single data element for to-one relationship.", - $"Expected single data element for '{hasOneRelationship.PublicName}' relationship.", atomicOperationIndex: AtomicOperationIndex); - } - - IIdentifiable rightResource = CreateRightResource(hasOneRelationship, relationshipData.SingleData); - hasOneRelationship.SetValue(resource, rightResource); - - // depending on if this base parser is used client-side or server-side, - // different additional processing per field needs to be executed. - AfterProcessField(resource, hasOneRelationship, relationshipData); - } - - /// - /// Sets a HasMany relationship. - /// - private void SetHasManyRelationship(IIdentifiable resource, HasManyAttribute hasManyRelationship, RelationshipEntry relationshipData) - { - if (relationshipData.ManyData == null) - { - throw new JsonApiSerializationException("Expected data[] element for to-many relationship.", - $"Expected data[] element for '{hasManyRelationship.PublicName}' relationship.", atomicOperationIndex: AtomicOperationIndex); - } - - HashSet rightResources = relationshipData.ManyData.Select(rio => CreateRightResource(hasManyRelationship, rio)) - .ToHashSet(IdentifiableComparer.Instance); - - IEnumerable convertedCollection = CollectionConverter.CopyToTypedCollection(rightResources, hasManyRelationship.Property.PropertyType); - hasManyRelationship.SetValue(resource, convertedCollection); - - AfterProcessField(resource, hasManyRelationship, relationshipData); - } - - private IIdentifiable CreateRightResource(RelationshipAttribute relationship, ResourceIdentifierObject resourceIdentifierObject) - { - if (resourceIdentifierObject != null) - { - AssertHasType(resourceIdentifierObject, relationship); - AssertHasIdOrLid(resourceIdentifierObject, relationship); - - ResourceContext rightResourceContext = GetExistingResourceContext(resourceIdentifierObject.Type); - AssertRightTypeIsCompatible(rightResourceContext, relationship); - - IIdentifiable rightInstance = ResourceFactory.CreateInstance(rightResourceContext.ResourceType); - rightInstance.StringId = resourceIdentifierObject.Id; - rightInstance.LocalId = resourceIdentifierObject.Lid; - - return rightInstance; - } - - return null; - } - - [AssertionMethod] - private void AssertHasType(ResourceIdentifierObject resourceIdentifierObject, RelationshipAttribute relationship) - { - if (resourceIdentifierObject.Type == null) - { - string details = relationship != null - ? $"Expected 'type' element in '{relationship.PublicName}' relationship." - : "Expected 'type' element in 'data' element."; - - throw new JsonApiSerializationException("Request body must include 'type' element.", details, atomicOperationIndex: AtomicOperationIndex); - } - } - - private void AssertHasIdOrLid(ResourceIdentifierObject resourceIdentifierObject, RelationshipAttribute relationship) - { - if (AtomicOperationIndex != null) - { - bool hasNone = resourceIdentifierObject.Id == null && resourceIdentifierObject.Lid == null; - bool hasBoth = resourceIdentifierObject.Id != null && resourceIdentifierObject.Lid != null; - - if (hasNone || hasBoth) - { - throw new JsonApiSerializationException("Request body must include 'id' or 'lid' element.", - $"Expected 'id' or 'lid' element in '{relationship.PublicName}' relationship.", atomicOperationIndex: AtomicOperationIndex); - } - } - else - { - if (resourceIdentifierObject.Id == null) - { - throw new JsonApiSerializationException("Request body must include 'id' element.", - $"Expected 'id' element in '{relationship.PublicName}' relationship.", atomicOperationIndex: AtomicOperationIndex); - } - - AssertHasNoLid(resourceIdentifierObject); - } - } - - [AssertionMethod] - private void AssertHasNoLid(ResourceIdentifierObject resourceIdentifierObject) - { - if (resourceIdentifierObject.Lid != null) - { - throw new JsonApiSerializationException("Local IDs cannot be used at this endpoint.", null, atomicOperationIndex: AtomicOperationIndex); - } - } - - private void AssertRightTypeIsCompatible(ResourceContext rightResourceContext, RelationshipAttribute relationship) - { - if (!relationship.RightType.IsAssignableFrom(rightResourceContext.ResourceType)) - { - throw new JsonApiSerializationException("Relationship contains incompatible resource type.", - $"Relationship '{relationship.PublicName}' contains incompatible resource type '{rightResourceContext.PublicName}'.", - atomicOperationIndex: AtomicOperationIndex); - } - } - - private object ConvertAttrValue(object newValue, Type targetType) - { - if (newValue is JContainer jObject) - { - // the attribute value is a complex type that needs additional deserialization - return DeserializeComplexType(jObject, targetType); - } - - // the attribute value is a native C# type. - object convertedValue = RuntimeTypeConverter.ConvertType(newValue, targetType); - return convertedValue; - } - - private object DeserializeComplexType(JContainer obj, Type targetType) - { - return obj.ToObject(targetType); - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/BaseSerializer.cs b/src/JsonApiDotNetCore/Serialization/BaseSerializer.cs deleted file mode 100644 index 33ebf99f64..0000000000 --- a/src/JsonApiDotNetCore/Serialization/BaseSerializer.cs +++ /dev/null @@ -1,105 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization.Building; -using JsonApiDotNetCore.Serialization.Objects; -using Newtonsoft.Json; - -namespace JsonApiDotNetCore.Serialization -{ - /// - /// Abstract base class for serialization. Uses to convert resources into s and wraps - /// them in a . - /// - public abstract class BaseSerializer - { - protected IResourceObjectBuilder ResourceObjectBuilder { get; } - - protected BaseSerializer(IResourceObjectBuilder resourceObjectBuilder) - { - ArgumentGuard.NotNull(resourceObjectBuilder, nameof(resourceObjectBuilder)); - - ResourceObjectBuilder = resourceObjectBuilder; - } - - /// - /// Builds a for . Adds the attributes and relationships that are enlisted in - /// and . - /// - /// - /// Resource to build a for. - /// - /// - /// Attributes to include in the building process. - /// - /// - /// Relationships to include in the building process. - /// - /// - /// The resource object that was built. - /// - protected Document Build(IIdentifiable resource, IReadOnlyCollection attributes, - IReadOnlyCollection relationships) - { - if (resource == null) - { - return new Document(); - } - - return new Document - { - Data = ResourceObjectBuilder.Build(resource, attributes, relationships) - }; - } - - /// - /// Builds a for . Adds the attributes and relationships that are enlisted in - /// and . - /// - /// - /// Resource to build a for. - /// - /// - /// Attributes to include in the building process. - /// - /// - /// Relationships to include in the building process. - /// - /// - /// The resource object that was built. - /// - protected Document Build(IReadOnlyCollection resources, IReadOnlyCollection attributes, - IReadOnlyCollection relationships) - { - ArgumentGuard.NotNull(resources, nameof(resources)); - - var data = new List(); - - foreach (IIdentifiable resource in resources) - { - data.Add(ResourceObjectBuilder.Build(resource, attributes, relationships)); - } - - return new Document - { - Data = data - }; - } - - protected string SerializeObject(object value, JsonSerializerSettings defaultSettings, Action changeSerializer = null) - { - ArgumentGuard.NotNull(defaultSettings, nameof(defaultSettings)); - - var serializer = JsonSerializer.CreateDefault(defaultSettings); - changeSerializer?.Invoke(serializer); - - using var stringWriter = new StringWriter(); - using var jsonWriter = new JsonTextWriter(stringWriter); - - serializer.Serialize(jsonWriter, value); - return stringWriter.ToString(); - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Building/IIncludedResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/IIncludedResourceObjectBuilder.cs deleted file mode 100644 index 3a49f0d413..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Building/IIncludedResourceObjectBuilder.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Serialization.Building -{ - public interface IIncludedResourceObjectBuilder - { - /// - /// Gets the list of resource objects representing the included resources. - /// - IList Build(); - - /// - /// Extracts the included resources from using the (arbitrarily deeply nested) included relationships in - /// . - /// - void IncludeRelationshipChain(IReadOnlyCollection inclusionChain, IIdentifiable rootResource); - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Building/ILinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/ILinkBuilder.cs deleted file mode 100644 index 17a492f9b2..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Building/ILinkBuilder.cs +++ /dev/null @@ -1,27 +0,0 @@ -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Serialization.Building -{ - /// - /// Builds resource object links and relationship object links. - /// - public interface ILinkBuilder - { - /// - /// Builds the links object that is included in the top-level of the document. - /// - TopLevelLinks GetTopLevelLinks(); - - /// - /// Builds the links object for resources in the primary data. - /// - ResourceLinks GetResourceLinks(string resourceName, string id); - - /// - /// Builds the links object that is included in the values of the . - /// - RelationshipLinks GetRelationshipLinks(RelationshipAttribute relationship, IIdentifiable parent); - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Building/IMetaBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/IMetaBuilder.cs deleted file mode 100644 index 1e668feca5..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Building/IMetaBuilder.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Collections.Generic; -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Serialization.Building -{ - /// - /// Builds the top-level meta object. - /// - [PublicAPI] - public interface IMetaBuilder - { - /// - /// Merges the specified dictionary with existing key/value pairs. In the event of a key collision, the value from the specified dictionary will - /// overwrite the existing one. - /// - void Add(IReadOnlyDictionary values); - - /// - /// Builds the top-level meta data object. - /// - IDictionary Build(); - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Building/IResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/IResourceObjectBuilder.cs deleted file mode 100644 index ff182c2dab..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Building/IResourceObjectBuilder.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Serialization.Building -{ - /// - /// Responsible for converting resources into s given a collection of attributes and relationships. - /// - public interface IResourceObjectBuilder - { - /// - /// Converts into a . Adds the attributes and relationships that are enlisted in - /// and . - /// - /// - /// Resource to build a for. - /// - /// - /// Attributes to include in the building process. - /// - /// - /// Relationships to include in the building process. - /// - /// - /// The resource object that was built. - /// - ResourceObject Build(IIdentifiable resource, IReadOnlyCollection attributes, IReadOnlyCollection relationships); - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Building/IResourceObjectBuilderSettingsProvider.cs b/src/JsonApiDotNetCore/Serialization/Building/IResourceObjectBuilderSettingsProvider.cs deleted file mode 100644 index 0fcaefd32a..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Building/IResourceObjectBuilderSettingsProvider.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace JsonApiDotNetCore.Serialization.Building -{ - /// - /// Service that provides the server serializer with . - /// - public interface IResourceObjectBuilderSettingsProvider - { - /// - /// Gets the behavior for the serializer it is injected in. - /// - ResourceObjectBuilderSettings Get(); - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs deleted file mode 100644 index 170eb2d97c..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Building/IncludedResourceObjectBuilder.cs +++ /dev/null @@ -1,222 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Internal; -using JsonApiDotNetCore.QueryStrings; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Serialization.Building -{ - [PublicAPI] - public class IncludedResourceObjectBuilder : ResourceObjectBuilder, IIncludedResourceObjectBuilder - { - private readonly HashSet _included; - private readonly IFieldsToSerialize _fieldsToSerialize; - private readonly ILinkBuilder _linkBuilder; - private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; - private readonly IRequestQueryStringAccessor _queryStringAccessor; - private readonly SparseFieldSetCache _sparseFieldSetCache; - - public IncludedResourceObjectBuilder(IFieldsToSerialize fieldsToSerialize, ILinkBuilder linkBuilder, IResourceContextProvider resourceContextProvider, - IEnumerable constraintProviders, IResourceDefinitionAccessor resourceDefinitionAccessor, - IRequestQueryStringAccessor queryStringAccessor, IResourceObjectBuilderSettingsProvider settingsProvider) - : base(resourceContextProvider, settingsProvider.Get()) - { - ArgumentGuard.NotNull(fieldsToSerialize, nameof(fieldsToSerialize)); - ArgumentGuard.NotNull(linkBuilder, nameof(linkBuilder)); - ArgumentGuard.NotNull(constraintProviders, nameof(constraintProviders)); - ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); - ArgumentGuard.NotNull(queryStringAccessor, nameof(queryStringAccessor)); - - _included = new HashSet(ResourceIdentifierObjectComparer.Instance); - _fieldsToSerialize = fieldsToSerialize; - _linkBuilder = linkBuilder; - _resourceDefinitionAccessor = resourceDefinitionAccessor; - _queryStringAccessor = queryStringAccessor; - _sparseFieldSetCache = new SparseFieldSetCache(constraintProviders, resourceDefinitionAccessor); - } - - /// - public IList Build() - { - if (_included.Any()) - { - // Cleans relationship dictionaries and adds links of resources. - foreach (ResourceObject resourceObject in _included) - { - if (resourceObject.Relationships != null) - { - UpdateRelationships(resourceObject); - } - - resourceObject.Links = _linkBuilder.GetResourceLinks(resourceObject.Type, resourceObject.Id); - } - - return _included.ToArray(); - } - - return _queryStringAccessor.Query.ContainsKey("include") ? Array.Empty() : null; - } - - private void UpdateRelationships(ResourceObject resourceObject) - { - foreach (string relationshipName in resourceObject.Relationships.Keys.ToArray()) - { - ResourceContext resourceContext = ResourceContextProvider.GetResourceContext(resourceObject.Type); - RelationshipAttribute relationship = resourceContext.Relationships.Single(rel => rel.PublicName == relationshipName); - - if (!IsRelationshipInSparseFieldSet(relationship)) - { - resourceObject.Relationships.Remove(relationshipName); - } - } - - resourceObject.Relationships = PruneRelationshipEntries(resourceObject); - } - - private static IDictionary PruneRelationshipEntries(ResourceObject resourceObject) - { - Dictionary pruned = resourceObject.Relationships.Where(pair => pair.Value.IsPopulated || pair.Value.Links != null) - .ToDictionary(pair => pair.Key, pair => pair.Value); - - return !pruned.Any() ? null : pruned; - } - - private bool IsRelationshipInSparseFieldSet(RelationshipAttribute relationship) - { - ResourceContext resourceContext = ResourceContextProvider.GetResourceContext(relationship.LeftType); - - IReadOnlyCollection fieldSet = _sparseFieldSetCache.GetSparseFieldSetForSerializer(resourceContext); - return fieldSet.Contains(relationship); - } - - /// - public override ResourceObject Build(IIdentifiable resource, IReadOnlyCollection attributes = null, - IReadOnlyCollection relationships = null) - { - ResourceObject resourceObject = base.Build(resource, attributes, relationships); - - resourceObject.Meta = _resourceDefinitionAccessor.GetMeta(resource.GetType(), resource); - - return resourceObject; - } - - /// - public void IncludeRelationshipChain(IReadOnlyCollection inclusionChain, IIdentifiable rootResource) - { - ArgumentGuard.NotNull(inclusionChain, nameof(inclusionChain)); - ArgumentGuard.NotNull(rootResource, nameof(rootResource)); - - // We don't have to build a resource object for the root resource because - // this one is already encoded in the documents primary data, so we process the chain - // starting from the first related resource. - RelationshipAttribute relationship = inclusionChain.First(); - IList chainRemainder = ShiftChain(inclusionChain); - object related = relationship.GetValue(rootResource); - ProcessChain(related, chainRemainder); - } - - private void ProcessChain(object related, IList inclusionChain) - { - if (related is IEnumerable children) - { - foreach (IIdentifiable child in children) - { - ProcessRelationship(child, inclusionChain); - } - } - else - { - ProcessRelationship((IIdentifiable)related, inclusionChain); - } - } - - private void ProcessRelationship(IIdentifiable parent, IList inclusionChain) - { - ResourceObject resourceObject = TryGetBuiltResourceObjectFor(parent); - - if (resourceObject == null) - { - _resourceDefinitionAccessor.OnSerialize(parent); - - resourceObject = BuildCachedResourceObjectFor(parent); - } - - if (!inclusionChain.Any()) - { - return; - } - - RelationshipAttribute nextRelationship = inclusionChain.First(); - List chainRemainder = inclusionChain.ToList(); - chainRemainder.RemoveAt(0); - - string nextRelationshipName = nextRelationship.PublicName; - IDictionary relationshipsObject = resourceObject.Relationships; - - // add the relationship entry in the relationship object. - if (!relationshipsObject.TryGetValue(nextRelationshipName, out RelationshipEntry relationshipEntry)) - { - relationshipEntry = GetRelationshipData(nextRelationship, parent); - relationshipsObject[nextRelationshipName] = relationshipEntry; - } - - relationshipEntry.Data = GetRelatedResourceLinkage(nextRelationship, parent); - - if (relationshipEntry.HasResource) - { - // if the relationship is set, continue parsing the chain. - object related = nextRelationship.GetValue(parent); - ProcessChain(related, chainRemainder); - } - } - - private IList ShiftChain(IReadOnlyCollection chain) - { - List chainRemainder = chain.ToList(); - chainRemainder.RemoveAt(0); - return chainRemainder; - } - - /// - /// We only need an empty relationship object entry here. It will be populated in the ProcessRelationships method. - /// - protected override RelationshipEntry GetRelationshipData(RelationshipAttribute relationship, IIdentifiable resource) - { - ArgumentGuard.NotNull(relationship, nameof(relationship)); - ArgumentGuard.NotNull(resource, nameof(resource)); - - return new RelationshipEntry - { - Links = _linkBuilder.GetRelationshipLinks(relationship, resource) - }; - } - - private ResourceObject TryGetBuiltResourceObjectFor(IIdentifiable resource) - { - Type resourceType = resource.GetType(); - ResourceContext resourceContext = ResourceContextProvider.GetResourceContext(resourceType); - - return _included.SingleOrDefault(resourceObject => resourceObject.Type == resourceContext.PublicName && resourceObject.Id == resource.StringId); - } - - private ResourceObject BuildCachedResourceObjectFor(IIdentifiable resource) - { - Type resourceType = resource.GetType(); - IReadOnlyCollection attributes = _fieldsToSerialize.GetAttributes(resourceType); - IReadOnlyCollection relationships = _fieldsToSerialize.GetRelationships(resourceType); - - ResourceObject resourceObject = Build(resource, attributes, relationships); - - _included.Add(resourceObject); - - return resourceObject; - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs deleted file mode 100644 index ea29bfa4d1..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs +++ /dev/null @@ -1,349 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal.Parsing; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Extensions; -using Microsoft.AspNetCore.Routing; - -namespace JsonApiDotNetCore.Serialization.Building -{ - [PublicAPI] - public class LinkBuilder : ILinkBuilder - { - private const string PageSizeParameterName = "page[size]"; - private const string PageNumberParameterName = "page[number]"; - - private static readonly string GetPrimaryControllerActionName = NoAsyncSuffix(nameof(BaseJsonApiController.GetAsync)); - private static readonly string GetSecondaryControllerActionName = NoAsyncSuffix(nameof(BaseJsonApiController.GetSecondaryAsync)); - private static readonly string GetRelationshipControllerActionName = NoAsyncSuffix(nameof(BaseJsonApiController.GetRelationshipAsync)); - - private readonly IJsonApiOptions _options; - private readonly IJsonApiRequest _request; - private readonly IPaginationContext _paginationContext; - private readonly IResourceContextProvider _resourceContextProvider; - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly LinkGenerator _linkGenerator; - private readonly IControllerResourceMapping _controllerResourceMapping; - - public LinkBuilder(IJsonApiOptions options, IJsonApiRequest request, IPaginationContext paginationContext, - IResourceContextProvider resourceContextProvider, IHttpContextAccessor httpContextAccessor, LinkGenerator linkGenerator, - IControllerResourceMapping controllerResourceMapping) - { - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(paginationContext, nameof(paginationContext)); - ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider)); - ArgumentGuard.NotNull(linkGenerator, nameof(linkGenerator)); - ArgumentGuard.NotNull(controllerResourceMapping, nameof(controllerResourceMapping)); - - _options = options; - _request = request; - _paginationContext = paginationContext; - _resourceContextProvider = resourceContextProvider; - _httpContextAccessor = httpContextAccessor; - _linkGenerator = linkGenerator; - _controllerResourceMapping = controllerResourceMapping; - } - - private static string NoAsyncSuffix(string actionName) - { - return actionName.EndsWith("Async", StringComparison.Ordinal) ? actionName[..^"Async".Length] : actionName; - } - - /// - public TopLevelLinks GetTopLevelLinks() - { - var links = new TopLevelLinks(); - - ResourceContext requestContext = _request.SecondaryResource ?? _request.PrimaryResource; - - if (ShouldIncludeTopLevelLink(LinkTypes.Self, requestContext)) - { - links.Self = GetLinkForTopLevelSelf(); - } - - if (_request.Kind == EndpointKind.Relationship && ShouldIncludeTopLevelLink(LinkTypes.Related, requestContext)) - { - links.Related = GetLinkForRelationshipRelated(_request.PrimaryId, _request.Relationship); - } - - if (_request.IsCollection && _paginationContext.PageSize != null && ShouldIncludeTopLevelLink(LinkTypes.Paging, requestContext)) - { - SetPaginationInTopLevelLinks(requestContext, links); - } - - return links.HasValue() ? links : null; - } - - /// - /// Checks if the top-level should be added by first checking configuration on the , and if - /// not configured, by checking with the global configuration in . - /// - private bool ShouldIncludeTopLevelLink(LinkTypes linkType, ResourceContext resourceContext) - { - if (resourceContext.TopLevelLinks != LinkTypes.NotConfigured) - { - return resourceContext.TopLevelLinks.HasFlag(linkType); - } - - return _options.TopLevelLinks.HasFlag(linkType); - } - - private string GetLinkForTopLevelSelf() - { - return _options.UseRelativeLinks - ? _httpContextAccessor.HttpContext.Request.GetEncodedPathAndQuery() - : _httpContextAccessor.HttpContext.Request.GetEncodedUrl(); - } - - private void SetPaginationInTopLevelLinks(ResourceContext requestContext, TopLevelLinks links) - { - string pageSizeValue = CalculatePageSizeValue(_paginationContext.PageSize, requestContext); - - links.First = GetLinkForPagination(1, pageSizeValue); - - if (_paginationContext.TotalPageCount > 0) - { - links.Last = GetLinkForPagination(_paginationContext.TotalPageCount.Value, pageSizeValue); - } - - if (_paginationContext.PageNumber.OneBasedValue > 1) - { - links.Prev = GetLinkForPagination(_paginationContext.PageNumber.OneBasedValue - 1, pageSizeValue); - } - - bool hasNextPage = _paginationContext.PageNumber.OneBasedValue < _paginationContext.TotalPageCount; - bool possiblyHasNextPage = _paginationContext.TotalPageCount == null && _paginationContext.IsPageFull; - - if (hasNextPage || possiblyHasNextPage) - { - links.Next = GetLinkForPagination(_paginationContext.PageNumber.OneBasedValue + 1, pageSizeValue); - } - } - - private string CalculatePageSizeValue(PageSize topPageSize, ResourceContext requestContext) - { - string pageSizeParameterValue = _httpContextAccessor.HttpContext.Request.Query[PageSizeParameterName]; - - PageSize newTopPageSize = Equals(topPageSize, _options.DefaultPageSize) ? null : topPageSize; - return ChangeTopPageSize(pageSizeParameterValue, newTopPageSize, requestContext); - } - - private string ChangeTopPageSize(string pageSizeParameterValue, PageSize topPageSize, ResourceContext requestContext) - { - IList elements = ParsePageSizeExpression(pageSizeParameterValue, requestContext); - int elementInTopScopeIndex = elements.FindIndex(expression => expression.Scope == null); - - if (topPageSize != null) - { - var topPageSizeElement = new PaginationElementQueryStringValueExpression(null, topPageSize.Value); - - if (elementInTopScopeIndex != -1) - { - elements[elementInTopScopeIndex] = topPageSizeElement; - } - else - { - elements.Insert(0, topPageSizeElement); - } - } - else - { - if (elementInTopScopeIndex != -1) - { - elements.RemoveAt(elementInTopScopeIndex); - } - } - - string parameterValue = string.Join(',', - elements.Select(expression => expression.Scope == null ? expression.Value.ToString() : $"{expression.Scope}:{expression.Value}")); - - return parameterValue == string.Empty ? null : parameterValue; - } - - private IList ParsePageSizeExpression(string pageSizeParameterValue, ResourceContext requestResource) - { - if (pageSizeParameterValue == null) - { - return new List(); - } - - var parser = new PaginationParser(_resourceContextProvider); - PaginationQueryStringValueExpression paginationExpression = parser.Parse(pageSizeParameterValue, requestResource); - - return paginationExpression.Elements.ToList(); - } - - private string GetLinkForPagination(int pageOffset, string pageSizeValue) - { - string queryStringValue = GetQueryStringInPaginationLink(pageOffset, pageSizeValue); - - var builder = new UriBuilder(_httpContextAccessor.HttpContext.Request.GetEncodedUrl()) - { - Query = queryStringValue - }; - - UriComponents components = _options.UseRelativeLinks ? UriComponents.PathAndQuery : UriComponents.AbsoluteUri; - return builder.Uri.GetComponents(components, UriFormat.SafeUnescaped); - } - - private string GetQueryStringInPaginationLink(int pageOffset, string pageSizeValue) - { - IDictionary parameters = - _httpContextAccessor.HttpContext.Request.Query.ToDictionary(pair => pair.Key, pair => pair.Value.ToString()); - - if (pageSizeValue == null) - { - parameters.Remove(PageSizeParameterName); - } - else - { - parameters[PageSizeParameterName] = pageSizeValue; - } - - if (pageOffset == 1) - { - parameters.Remove(PageNumberParameterName); - } - else - { - parameters[PageNumberParameterName] = pageOffset.ToString(); - } - - string queryStringValue = QueryString.Create(parameters).Value; - return DecodeSpecialCharacters(queryStringValue); - } - - private static string DecodeSpecialCharacters(string uri) - { - return uri.Replace("%5B", "[").Replace("%5D", "]").Replace("%27", "'").Replace("%3A", ":"); - } - - /// - public ResourceLinks GetResourceLinks(string resourceName, string id) - { - ArgumentGuard.NotNullNorEmpty(resourceName, nameof(resourceName)); - ArgumentGuard.NotNullNorEmpty(id, nameof(id)); - - var links = new ResourceLinks(); - ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(resourceName); - - if (_request.Kind != EndpointKind.Relationship && ShouldIncludeResourceLink(LinkTypes.Self, resourceContext)) - { - links.Self = GetLinkForResourceSelf(resourceContext, id); - } - - return links.HasValue() ? links : null; - } - - /// - /// Checks if the resource object level should be added by first checking configuration on the - /// , and if not configured, by checking with the global configuration in . - /// - private bool ShouldIncludeResourceLink(LinkTypes linkType, ResourceContext resourceContext) - { - if (resourceContext.ResourceLinks != LinkTypes.NotConfigured) - { - return resourceContext.ResourceLinks.HasFlag(linkType); - } - - return _options.ResourceLinks.HasFlag(linkType); - } - - private string GetLinkForResourceSelf(ResourceContext resourceContext, string resourceId) - { - string controllerName = _controllerResourceMapping.GetControllerNameForResourceType(resourceContext.ResourceType); - IDictionary routeValues = GetRouteValues(resourceId, null); - - return RenderLinkForAction(controllerName, GetPrimaryControllerActionName, routeValues); - } - - /// - public RelationshipLinks GetRelationshipLinks(RelationshipAttribute relationship, IIdentifiable parent) - { - ArgumentGuard.NotNull(relationship, nameof(relationship)); - ArgumentGuard.NotNull(parent, nameof(parent)); - - var links = new RelationshipLinks(); - ResourceContext leftResourceContext = _resourceContextProvider.GetResourceContext(parent.GetType()); - - if (ShouldIncludeRelationshipLink(LinkTypes.Self, relationship, leftResourceContext)) - { - links.Self = GetLinkForRelationshipSelf(parent.StringId, relationship); - } - - if (ShouldIncludeRelationshipLink(LinkTypes.Related, relationship, leftResourceContext)) - { - links.Related = GetLinkForRelationshipRelated(parent.StringId, relationship); - } - - return links.HasValue() ? links : null; - } - - private string GetLinkForRelationshipSelf(string primaryId, RelationshipAttribute relationship) - { - string controllerName = _controllerResourceMapping.GetControllerNameForResourceType(relationship.LeftType); - IDictionary routeValues = GetRouteValues(primaryId, relationship.PublicName); - - return RenderLinkForAction(controllerName, GetRelationshipControllerActionName, routeValues); - } - - private string GetLinkForRelationshipRelated(string primaryId, RelationshipAttribute relationship) - { - string controllerName = _controllerResourceMapping.GetControllerNameForResourceType(relationship.LeftType); - IDictionary routeValues = GetRouteValues(primaryId, relationship.PublicName); - - return RenderLinkForAction(controllerName, GetSecondaryControllerActionName, routeValues); - } - - private IDictionary 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, - // so users must override RenderLinkForAction to supply them, if applicable. - RouteValueDictionary routeValues = _httpContextAccessor.HttpContext.Request.RouteValues; - - routeValues["id"] = primaryId; - routeValues["relationshipName"] = relationshipName; - - return routeValues; - } - - protected virtual string RenderLinkForAction(string controllerName, string actionName, IDictionary routeValues) - { - return _options.UseRelativeLinks - ? _linkGenerator.GetPathByAction(_httpContextAccessor.HttpContext, actionName, controllerName, routeValues) - : _linkGenerator.GetUriByAction(_httpContextAccessor.HttpContext, actionName, controllerName, routeValues); - } - - /// - /// Checks if the relationship object level should be added by first checking configuration on the - /// attribute, if not configured by checking on the resource - /// type that contains this relationship, and if not configured by checking with the global configuration in . - /// - private bool ShouldIncludeRelationshipLink(LinkTypes linkType, RelationshipAttribute relationship, ResourceContext leftResourceContext) - { - if (relationship.Links != LinkTypes.NotConfigured) - { - return relationship.Links.HasFlag(linkType); - } - - if (leftResourceContext.RelationshipLinks != LinkTypes.NotConfigured) - { - return leftResourceContext.RelationshipLinks.HasFlag(linkType); - } - - return _options.RelationshipLinks.HasFlag(linkType); - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Building/MetaBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/MetaBuilder.cs deleted file mode 100644 index 6d35b8385b..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Building/MetaBuilder.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries; - -namespace JsonApiDotNetCore.Serialization.Building -{ - /// - [PublicAPI] - public class MetaBuilder : IMetaBuilder - { - private readonly IPaginationContext _paginationContext; - private readonly IJsonApiOptions _options; - private readonly IResponseMeta _responseMeta; - - private Dictionary _meta = new Dictionary(); - - public MetaBuilder(IPaginationContext paginationContext, IJsonApiOptions options, IResponseMeta responseMeta) - { - ArgumentGuard.NotNull(paginationContext, nameof(paginationContext)); - ArgumentGuard.NotNull(options, nameof(options)); - ArgumentGuard.NotNull(responseMeta, nameof(responseMeta)); - - _paginationContext = paginationContext; - _options = options; - _responseMeta = responseMeta; - } - - /// - public void Add(IReadOnlyDictionary values) - { - ArgumentGuard.NotNull(values, nameof(values)); - - _meta = values.Keys.Union(_meta.Keys).ToDictionary(key => key, key => values.ContainsKey(key) ? values[key] : _meta[key]); - } - - /// - public IDictionary Build() - { - if (_paginationContext.TotalResourceCount != null) - { - string key = _options.SerializerNamingStrategy.GetPropertyName("TotalResources", false); - - _meta.Add(key, _paginationContext.TotalResourceCount); - } - - IReadOnlyDictionary extraMeta = _responseMeta.GetMeta(); - - if (extraMeta != null) - { - Add(extraMeta); - } - - return _meta.Any() ? _meta : null; - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Building/ResourceIdentifierObjectComparer.cs b/src/JsonApiDotNetCore/Serialization/Building/ResourceIdentifierObjectComparer.cs deleted file mode 100644 index b23828581d..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Building/ResourceIdentifierObjectComparer.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System; -using System.Collections.Generic; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Serialization.Building -{ - internal sealed class ResourceIdentifierObjectComparer : IEqualityComparer - { - public static readonly ResourceIdentifierObjectComparer Instance = new ResourceIdentifierObjectComparer(); - - private ResourceIdentifierObjectComparer() - { - } - - public bool Equals(ResourceIdentifierObject x, ResourceIdentifierObject y) - { - if (ReferenceEquals(x, y)) - { - return true; - } - - if (x is null || y is null || x.GetType() != y.GetType()) - { - return false; - } - - return x.Type == y.Type && x.Id == y.Id && x.Lid == y.Lid; - } - - public int GetHashCode(ResourceIdentifierObject obj) - { - return HashCode.Combine(obj.Type, obj.Id, obj.Lid); - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs deleted file mode 100644 index 3f9e0b3a8e..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilder.cs +++ /dev/null @@ -1,187 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Resources.Internal; -using JsonApiDotNetCore.Serialization.Objects; -using Newtonsoft.Json; - -namespace JsonApiDotNetCore.Serialization.Building -{ - /// - [PublicAPI] - public class ResourceObjectBuilder : IResourceObjectBuilder - { - private static readonly CollectionConverter CollectionConverter = new CollectionConverter(); - - private readonly ResourceObjectBuilderSettings _settings; - protected IResourceContextProvider ResourceContextProvider { get; } - - public ResourceObjectBuilder(IResourceContextProvider resourceContextProvider, ResourceObjectBuilderSettings settings) - { - ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider)); - ArgumentGuard.NotNull(settings, nameof(settings)); - - ResourceContextProvider = resourceContextProvider; - _settings = settings; - } - - /// - public virtual ResourceObject Build(IIdentifiable resource, IReadOnlyCollection attributes = null, - IReadOnlyCollection relationships = null) - { - ArgumentGuard.NotNull(resource, nameof(resource)); - - ResourceContext resourceContext = ResourceContextProvider.GetResourceContext(resource.GetType()); - - // populating the top-level "type" and "id" members. - var resourceObject = new ResourceObject - { - Type = resourceContext.PublicName, - Id = resource.StringId - }; - - // populating the top-level "attribute" member of a resource object. never include "id" as an attribute - if (attributes != null) - { - AttrAttribute[] attributesWithoutId = attributes.Where(attr => attr.Property.Name != nameof(Identifiable.Id)).ToArray(); - - if (attributesWithoutId.Any()) - { - ProcessAttributes(resource, attributesWithoutId, resourceObject); - } - } - - // populating the top-level "relationship" member of a resource object. - if (relationships != null) - { - ProcessRelationships(resource, relationships, resourceObject); - } - - return resourceObject; - } - - /// - /// Builds the entries of the "relationships objects". The default behavior is to just construct a resource linkage with - /// the "data" field populated with "single" or "many" data. Depending on the requirements of the implementation (server or client serializer), this may - /// be overridden. - /// - protected virtual RelationshipEntry GetRelationshipData(RelationshipAttribute relationship, IIdentifiable resource) - { - ArgumentGuard.NotNull(relationship, nameof(relationship)); - ArgumentGuard.NotNull(resource, nameof(resource)); - - return new RelationshipEntry - { - Data = GetRelatedResourceLinkage(relationship, resource) - }; - } - - /// - /// Gets the value for the property. - /// - protected object GetRelatedResourceLinkage(RelationshipAttribute relationship, IIdentifiable resource) - { - ArgumentGuard.NotNull(relationship, nameof(relationship)); - ArgumentGuard.NotNull(resource, nameof(resource)); - - return relationship is HasOneAttribute hasOne - ? (object)GetRelatedResourceLinkageForHasOne(hasOne, resource) - : GetRelatedResourceLinkageForHasMany((HasManyAttribute)relationship, resource); - } - - /// - /// Builds a for a HasOne relationship. - /// - private ResourceIdentifierObject GetRelatedResourceLinkageForHasOne(HasOneAttribute relationship, IIdentifiable resource) - { - var relatedResource = (IIdentifiable)relationship.GetValue(resource); - - if (relatedResource != null) - { - return GetResourceIdentifier(relatedResource); - } - - return null; - } - - /// - /// Builds the s for a HasMany relationship. - /// - private IList GetRelatedResourceLinkageForHasMany(HasManyAttribute relationship, IIdentifiable resource) - { - object value = relationship.GetValue(resource); - ICollection relatedResources = CollectionConverter.ExtractResources(value); - - var manyData = new List(); - - if (relatedResources != null) - { - foreach (IIdentifiable relatedResource in relatedResources) - { - manyData.Add(GetResourceIdentifier(relatedResource)); - } - } - - return manyData; - } - - /// - /// Creates a from . - /// - private ResourceIdentifierObject GetResourceIdentifier(IIdentifiable resource) - { - string resourceName = ResourceContextProvider.GetResourceContext(resource.GetType()).PublicName; - - return new ResourceIdentifierObject - { - Type = resourceName, - Id = resource.StringId - }; - } - - /// - /// Puts the relationships of the resource into the resource object. - /// - private void ProcessRelationships(IIdentifiable resource, IEnumerable relationships, ResourceObject ro) - { - foreach (RelationshipAttribute rel in relationships) - { - RelationshipEntry relData = GetRelationshipData(rel, resource); - - if (relData != null) - { - (ro.Relationships ??= new Dictionary()).Add(rel.PublicName, relData); - } - } - } - - /// - /// Puts the attributes of the resource into the resource object. - /// - private void ProcessAttributes(IIdentifiable resource, IEnumerable attributes, ResourceObject ro) - { - ro.Attributes = new Dictionary(); - - foreach (AttrAttribute attr in attributes) - { - object value = attr.GetValue(resource); - - if (_settings.SerializerNullValueHandling == NullValueHandling.Ignore && value == null) - { - return; - } - - if (_settings.SerializerDefaultValueHandling == DefaultValueHandling.Ignore && - Equals(value, RuntimeTypeConverter.GetDefaultValue(attr.Property.PropertyType))) - { - return; - } - - ro.Attributes.Add(attr.PublicName, value); - } - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilderSettings.cs b/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilderSettings.cs deleted file mode 100644 index cef1f0e5dd..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilderSettings.cs +++ /dev/null @@ -1,23 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Serialization.Objects; -using Newtonsoft.Json; - -namespace JsonApiDotNetCore.Serialization.Building -{ - /// - /// Options used to configure how fields of a model get serialized into a JSON:API . - /// - [PublicAPI] - public sealed class ResourceObjectBuilderSettings - { - public NullValueHandling SerializerNullValueHandling { get; } - public DefaultValueHandling SerializerDefaultValueHandling { get; } - - public ResourceObjectBuilderSettings(NullValueHandling serializerNullValueHandling = NullValueHandling.Include, - DefaultValueHandling serializerDefaultValueHandling = DefaultValueHandling.Include) - { - SerializerNullValueHandling = serializerNullValueHandling; - SerializerDefaultValueHandling = serializerDefaultValueHandling; - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilderSettingsProvider.cs b/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilderSettingsProvider.cs deleted file mode 100644 index d28a7591eb..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Building/ResourceObjectBuilderSettingsProvider.cs +++ /dev/null @@ -1,30 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.QueryStrings; - -namespace JsonApiDotNetCore.Serialization.Building -{ - /// - /// This implementation of the behavior provider reads the defaults/nulls query string parameters that can, if provided, override the settings in - /// . - /// - public sealed class ResourceObjectBuilderSettingsProvider : IResourceObjectBuilderSettingsProvider - { - private readonly IDefaultsQueryStringParameterReader _defaultsReader; - private readonly INullsQueryStringParameterReader _nullsReader; - - public ResourceObjectBuilderSettingsProvider(IDefaultsQueryStringParameterReader defaultsReader, INullsQueryStringParameterReader nullsReader) - { - ArgumentGuard.NotNull(defaultsReader, nameof(defaultsReader)); - ArgumentGuard.NotNull(nullsReader, nameof(nullsReader)); - - _defaultsReader = defaultsReader; - _nullsReader = nullsReader; - } - - /// - public ResourceObjectBuilderSettings Get() - { - return new ResourceObjectBuilderSettings(_nullsReader.SerializerNullValueHandling, _defaultsReader.SerializerDefaultValueHandling); - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Building/ResponseResourceObjectBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/ResponseResourceObjectBuilder.cs deleted file mode 100644 index 62e36f1e37..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Building/ResponseResourceObjectBuilder.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Queries.Internal; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Serialization.Building -{ - [PublicAPI] - public class ResponseResourceObjectBuilder : ResourceObjectBuilder - { - private static readonly IncludeChainConverter IncludeChainConverter = new IncludeChainConverter(); - - private readonly ILinkBuilder _linkBuilder; - private readonly IIncludedResourceObjectBuilder _includedBuilder; - private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; - private readonly IEvaluatedIncludeCache _evaluatedIncludeCache; - private readonly SparseFieldSetCache _sparseFieldSetCache; - - private RelationshipAttribute _requestRelationship; - - public ResponseResourceObjectBuilder(ILinkBuilder linkBuilder, IIncludedResourceObjectBuilder includedBuilder, - IEnumerable constraintProviders, IResourceContextProvider resourceContextProvider, - IResourceDefinitionAccessor resourceDefinitionAccessor, IResourceObjectBuilderSettingsProvider settingsProvider, - IEvaluatedIncludeCache evaluatedIncludeCache) - : base(resourceContextProvider, settingsProvider.Get()) - { - ArgumentGuard.NotNull(linkBuilder, nameof(linkBuilder)); - ArgumentGuard.NotNull(includedBuilder, nameof(includedBuilder)); - ArgumentGuard.NotNull(constraintProviders, nameof(constraintProviders)); - ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); - ArgumentGuard.NotNull(evaluatedIncludeCache, nameof(evaluatedIncludeCache)); - - _linkBuilder = linkBuilder; - _includedBuilder = includedBuilder; - _resourceDefinitionAccessor = resourceDefinitionAccessor; - _evaluatedIncludeCache = evaluatedIncludeCache; - _sparseFieldSetCache = new SparseFieldSetCache(constraintProviders, resourceDefinitionAccessor); - } - - public RelationshipEntry Build(IIdentifiable resource, RelationshipAttribute requestRelationship) - { - ArgumentGuard.NotNull(resource, nameof(resource)); - ArgumentGuard.NotNull(requestRelationship, nameof(requestRelationship)); - - _requestRelationship = requestRelationship; - return GetRelationshipData(requestRelationship, resource); - } - - /// - public override ResourceObject Build(IIdentifiable resource, IReadOnlyCollection attributes = null, - IReadOnlyCollection relationships = null) - { - ResourceObject resourceObject = base.Build(resource, attributes, relationships); - - resourceObject.Meta = _resourceDefinitionAccessor.GetMeta(resource.GetType(), resource); - - return resourceObject; - } - - /// - /// Builds the values of the relationships object on a resource object. The server serializer only populates the "data" member when the relationship is - /// included, and adds links unless these are turned off. This means that if a relationship is not included and links are turned off, the entry would be - /// completely empty, ie { }, which is not conform JSON:API spec. In that case we return null which will omit the entry from the output. - /// - protected override RelationshipEntry GetRelationshipData(RelationshipAttribute relationship, IIdentifiable resource) - { - ArgumentGuard.NotNull(relationship, nameof(relationship)); - ArgumentGuard.NotNull(resource, nameof(resource)); - - RelationshipEntry relationshipEntry = null; - IReadOnlyCollection> relationshipChains = GetInclusionChainsStartingWith(relationship); - - if (Equals(relationship, _requestRelationship) || relationshipChains.Any()) - { - relationshipEntry = base.GetRelationshipData(relationship, resource); - - if (relationshipChains.Any() && relationshipEntry.HasResource) - { - foreach (IReadOnlyCollection chain in relationshipChains) - { - // traverses (recursively) and extracts all (nested) related resources for the current inclusion chain. - _includedBuilder.IncludeRelationshipChain(chain, resource); - } - } - } - - if (!IsRelationshipInSparseFieldSet(relationship)) - { - return null; - } - - RelationshipLinks links = _linkBuilder.GetRelationshipLinks(relationship, resource); - - if (links != null) - { - // if relationshipLinks should be built for this entry, populate the "links" field. - relationshipEntry ??= new RelationshipEntry(); - relationshipEntry.Links = links; - } - - // if neither "links" nor "data" was populated, return null, which will omit this entry from the output. - // (see the NullValueHandling settings on ) - return relationshipEntry; - } - - private bool IsRelationshipInSparseFieldSet(RelationshipAttribute relationship) - { - ResourceContext resourceContext = ResourceContextProvider.GetResourceContext(relationship.LeftType); - - IReadOnlyCollection fieldSet = _sparseFieldSetCache.GetSparseFieldSetForSerializer(resourceContext); - return fieldSet.Contains(relationship); - } - - /// - /// Inspects the included relationship chains and selects the ones that starts with the specified relationship. - /// - private IReadOnlyCollection> GetInclusionChainsStartingWith(RelationshipAttribute relationship) - { - IncludeExpression include = _evaluatedIncludeCache.Get() ?? IncludeExpression.Empty; - IReadOnlyCollection chains = IncludeChainConverter.GetRelationshipChains(include); - - var inclusionChains = new List>(); - - foreach (ResourceFieldChainExpression chain in chains) - { - if (chain.Fields.First().Equals(relationship)) - { - inclusionChains.Add(chain.Fields.Cast().ToArray()); - } - } - - return inclusionChains; - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Client/Internal/DeserializedResponseBase.cs b/src/JsonApiDotNetCore/Serialization/Client/Internal/DeserializedResponseBase.cs deleted file mode 100644 index f1051dfd47..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Client/Internal/DeserializedResponseBase.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Serialization.Client.Internal -{ - /// - /// Base class for "single data" and "many data" deserialized responses. - /// - [PublicAPI] - public abstract class DeserializedResponseBase - { - public TopLevelLinks Links { get; set; } - public IDictionary Meta { get; set; } - public object Errors { get; set; } - public object JsonApi { get; set; } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Client/Internal/IRequestSerializer.cs b/src/JsonApiDotNetCore/Serialization/Client/Internal/IRequestSerializer.cs deleted file mode 100644 index d58d7d9957..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Client/Internal/IRequestSerializer.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Serialization.Client.Internal -{ - /// - /// Interface for client serializer that can be used to register with the DI container, for usage in custom services or repositories. - /// - [PublicAPI] - public interface IRequestSerializer - { - /// - /// Sets the attributes that will be included in the serialized request body. You can use to - /// conveniently access the desired instances. - /// - public IReadOnlyCollection AttributesToSerialize { get; set; } - - /// - /// Sets the relationships that will be included in the serialized request body. You can use to - /// conveniently access the desired instances. - /// - public IReadOnlyCollection RelationshipsToSerialize { get; set; } - - /// - /// Creates and serializes a document for a single resource. - /// - /// - /// The serialized content - /// - string Serialize(IIdentifiable resource); - - /// - /// Creates and serializes a document for a collection of resources. - /// - /// - /// The serialized content - /// - string Serialize(IReadOnlyCollection resources); - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Client/Internal/IResponseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/Client/Internal/IResponseDeserializer.cs deleted file mode 100644 index 0a41306523..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Client/Internal/IResponseDeserializer.cs +++ /dev/null @@ -1,37 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCore.Serialization.Client.Internal -{ - /// - /// Client deserializer. Currently not used internally in JsonApiDotNetCore, except for in the tests. Exposed publicly to make testing easier or to - /// implement server-to-server communication. - /// - [PublicAPI] - public interface IResponseDeserializer - { - /// - /// Deserializes a response with a single resource (or null) as data. - /// - /// - /// The type of the resources in the primary data. - /// - /// - /// The JSON to be deserialized. - /// - SingleResponse DeserializeSingle(string body) - where TResource : class, IIdentifiable; - - /// - /// Deserializes a response with an (empty) collection of resources as data. - /// - /// - /// The type of the resources in the primary data. - /// - /// - /// The JSON to be deserialized. - /// - ManyResponse DeserializeMany(string body) - where TResource : class, IIdentifiable; - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Client/Internal/ManyResponse.cs b/src/JsonApiDotNetCore/Serialization/Client/Internal/ManyResponse.cs deleted file mode 100644 index 659e33d0dd..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Client/Internal/ManyResponse.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCore.Serialization.Client.Internal -{ - /// - /// Represents a deserialized document with "many data". - /// - /// - /// Type of the resource(s) in the primary data. - /// - [PublicAPI] - public sealed class ManyResponse : DeserializedResponseBase - where TResource : class, IIdentifiable - { - public IReadOnlyCollection Data { get; set; } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Client/Internal/RequestSerializer.cs b/src/JsonApiDotNetCore/Serialization/Client/Internal/RequestSerializer.cs deleted file mode 100644 index 73f0964a9f..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Client/Internal/RequestSerializer.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization.Building; -using JsonApiDotNetCore.Serialization.Objects; -using Newtonsoft.Json; - -namespace JsonApiDotNetCore.Serialization.Client.Internal -{ - /// - /// Client serializer implementation of . - /// - [PublicAPI] - public class RequestSerializer : BaseSerializer, IRequestSerializer - { - private readonly IResourceGraph _resourceGraph; - private readonly JsonSerializerSettings _jsonSerializerSettings = new JsonSerializerSettings(); - private Type _currentTargetedResource; - - /// - public IReadOnlyCollection AttributesToSerialize { get; set; } - - /// - public IReadOnlyCollection RelationshipsToSerialize { get; set; } - - public RequestSerializer(IResourceGraph resourceGraph, IResourceObjectBuilder resourceObjectBuilder) - : base(resourceObjectBuilder) - { - ArgumentGuard.NotNull(resourceGraph, nameof(resourceGraph)); - - _resourceGraph = resourceGraph; - } - - /// - public string Serialize(IIdentifiable resource) - { - if (resource == null) - { - Document empty = Build((IIdentifiable)null, Array.Empty(), Array.Empty()); - return SerializeObject(empty, _jsonSerializerSettings); - } - - _currentTargetedResource = resource.GetType(); - Document document = Build(resource, GetAttributesToSerialize(resource), RelationshipsToSerialize); - _currentTargetedResource = null; - - return SerializeObject(document, _jsonSerializerSettings); - } - - /// - public string Serialize(IReadOnlyCollection resources) - { - ArgumentGuard.NotNull(resources, nameof(resources)); - - IIdentifiable firstResource = resources.FirstOrDefault(); - - Document document; - - if (firstResource == null) - { - document = Build(resources, Array.Empty(), Array.Empty()); - } - else - { - _currentTargetedResource = firstResource.GetType(); - IReadOnlyCollection attributes = GetAttributesToSerialize(firstResource); - - document = Build(resources, attributes, RelationshipsToSerialize); - _currentTargetedResource = null; - } - - return SerializeObject(document, _jsonSerializerSettings); - } - - /// - /// By default, the client serializer includes all attributes in the result, unless a list of allowed attributes was supplied using the - /// method. For any related resources, attributes are never exposed. - /// - private IReadOnlyCollection GetAttributesToSerialize(IIdentifiable resource) - { - Type currentResourceType = resource.GetType(); - - if (_currentTargetedResource != currentResourceType) - { - // We're dealing with a relationship that is being serialized, for which - // we never want to include any attributes in the request body. - return new List(); - } - - if (AttributesToSerialize == null) - { - return _resourceGraph.GetAttributes(currentResourceType); - } - - return AttributesToSerialize; - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Client/Internal/ResponseDeserializer.cs b/src/JsonApiDotNetCore/Serialization/Client/Internal/ResponseDeserializer.cs deleted file mode 100644 index a08c3655fd..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Client/Internal/ResponseDeserializer.cs +++ /dev/null @@ -1,149 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Serialization.Client.Internal -{ - /// - /// Client deserializer implementation of the . - /// - [PublicAPI] - public class ResponseDeserializer : BaseDeserializer, IResponseDeserializer - { - public ResponseDeserializer(IResourceContextProvider resourceContextProvider, IResourceFactory resourceFactory) - : base(resourceContextProvider, resourceFactory) - { - } - - /// - public SingleResponse DeserializeSingle(string body) - where TResource : class, IIdentifiable - { - ArgumentGuard.NotNullNorEmpty(body, nameof(body)); - - object resource = DeserializeBody(body); - - return new SingleResponse - { - Links = Document.Links, - Meta = Document.Meta, - Data = (TResource)resource, - JsonApi = null, - Errors = null - }; - } - - /// - public ManyResponse DeserializeMany(string body) - where TResource : class, IIdentifiable - { - ArgumentGuard.NotNullNorEmpty(body, nameof(body)); - - object resources = DeserializeBody(body); - - return new ManyResponse - { - Links = Document.Links, - Meta = Document.Meta, - Data = ((ICollection)resources)?.Cast().ToArray(), - JsonApi = null, - Errors = null - }; - } - - /// - /// Additional processing required for client deserialization, responsible for parsing the property. When a relationship - /// value is parsed, it goes through the included list to set its attributes and relationships. - /// - /// - /// The resource that was constructed from the document's body. - /// - /// - /// The metadata for the exposed field. - /// - /// - /// Relationship data for . Is null when is not a . - /// - protected override void AfterProcessField(IIdentifiable resource, ResourceFieldAttribute field, RelationshipEntry data = null) - { - ArgumentGuard.NotNull(resource, nameof(resource)); - ArgumentGuard.NotNull(field, nameof(field)); - - // Client deserializers do not need additional processing for attributes. - if (field is AttrAttribute) - { - return; - } - - // if the included property is empty or absent, there is no additional data to be parsed. - if (Document.Included.IsNullOrEmpty()) - { - return; - } - - if (data != null) - { - if (field is HasOneAttribute hasOneAttr) - { - // add attributes and relationships of a parsed HasOne relationship - ResourceIdentifierObject rio = data.SingleData; - hasOneAttr.SetValue(resource, rio == null ? null : ParseIncludedRelationship(rio)); - } - else if (field is HasManyAttribute hasManyAttr) - { - // add attributes and relationships of a parsed HasMany relationship - IEnumerable items = data.ManyData.Select(ParseIncludedRelationship); - IEnumerable values = CollectionConverter.CopyToTypedCollection(items, hasManyAttr.Property.PropertyType); - hasManyAttr.SetValue(resource, values); - } - } - } - - /// - /// Searches for and parses the included relationship. - /// - private IIdentifiable ParseIncludedRelationship(ResourceIdentifierObject relatedResourceIdentifier) - { - ResourceContext relatedResourceContext = ResourceContextProvider.GetResourceContext(relatedResourceIdentifier.Type); - - if (relatedResourceContext == null) - { - throw new InvalidOperationException($"Included type '{relatedResourceIdentifier.Type}' is not a registered JSON:API resource."); - } - - IIdentifiable relatedInstance = ResourceFactory.CreateInstance(relatedResourceContext.ResourceType); - relatedInstance.StringId = relatedResourceIdentifier.Id; - - ResourceObject includedResource = GetLinkedResource(relatedResourceIdentifier); - - if (includedResource != null) - { - SetAttributes(relatedInstance, includedResource.Attributes, relatedResourceContext.Attributes); - SetRelationships(relatedInstance, includedResource.Relationships, relatedResourceContext.Relationships); - } - - return relatedInstance; - } - - private ResourceObject GetLinkedResource(ResourceIdentifierObject relatedResourceIdentifier) - { - try - { - return Document.Included.SingleOrDefault(resourceObject => - resourceObject.Type == relatedResourceIdentifier.Type && resourceObject.Id == relatedResourceIdentifier.Id); - } - catch (InvalidOperationException exception) - { - throw new InvalidOperationException( - "A compound document MUST NOT include more than one resource object for each type and ID pair." + - $"The duplicate pair was '{relatedResourceIdentifier.Type}, {relatedResourceIdentifier.Id}'", exception); - } - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Client/Internal/SingleResponse.cs b/src/JsonApiDotNetCore/Serialization/Client/Internal/SingleResponse.cs deleted file mode 100644 index 3359cafae6..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Client/Internal/SingleResponse.cs +++ /dev/null @@ -1,18 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCore.Serialization.Client.Internal -{ - /// - /// Represents a deserialized document with "single data". - /// - /// - /// Type of the resource in the primary data. - /// - [PublicAPI] - public sealed class SingleResponse : DeserializedResponseBase - where TResource : class, IIdentifiable - { - public TResource Data { get; set; } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/ETagGenerator.cs b/src/JsonApiDotNetCore/Serialization/ETagGenerator.cs deleted file mode 100644 index 88648a70d2..0000000000 --- a/src/JsonApiDotNetCore/Serialization/ETagGenerator.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Microsoft.Net.Http.Headers; - -namespace JsonApiDotNetCore.Serialization -{ - /// - internal sealed class ETagGenerator : IETagGenerator - { - private readonly IFingerprintGenerator _fingerprintGenerator; - - public ETagGenerator(IFingerprintGenerator fingerprintGenerator) - { - ArgumentGuard.NotNull(fingerprintGenerator, nameof(fingerprintGenerator)); - - _fingerprintGenerator = fingerprintGenerator; - } - - /// - public EntityTagHeaderValue Generate(string requestUrl, string responseBody) - { - string fingerprint = _fingerprintGenerator.Generate(ArrayFactory.Create(requestUrl, responseBody)); - string eTagValue = "\"" + fingerprint + "\""; - - return EntityTagHeaderValue.Parse(eTagValue); - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/EmptyResponseMeta.cs b/src/JsonApiDotNetCore/Serialization/EmptyResponseMeta.cs deleted file mode 100644 index 592a752926..0000000000 --- a/src/JsonApiDotNetCore/Serialization/EmptyResponseMeta.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Collections.Generic; - -namespace JsonApiDotNetCore.Serialization -{ - /// - public sealed class EmptyResponseMeta : IResponseMeta - { - /// - public IReadOnlyDictionary GetMeta() - { - return null; - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs b/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs deleted file mode 100644 index 763ee59b6c..0000000000 --- a/src/JsonApiDotNetCore/Serialization/FieldsToSerialize.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Internal; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Serialization -{ - /// - [PublicAPI] - public class FieldsToSerialize : IFieldsToSerialize - { - private readonly IResourceContextProvider _resourceContextProvider; - private readonly IJsonApiRequest _request; - private readonly SparseFieldSetCache _sparseFieldSetCache; - - /// - public bool ShouldSerialize => _request.Kind != EndpointKind.Relationship; - - public FieldsToSerialize(IResourceContextProvider resourceContextProvider, IEnumerable constraintProviders, - IResourceDefinitionAccessor resourceDefinitionAccessor, IJsonApiRequest request) - { - ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider)); - ArgumentGuard.NotNull(request, nameof(request)); - - _resourceContextProvider = resourceContextProvider; - _request = request; - _sparseFieldSetCache = new SparseFieldSetCache(constraintProviders, resourceDefinitionAccessor); - } - - /// - public IReadOnlyCollection GetAttributes(Type resourceType) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - - if (!ShouldSerialize) - { - return Array.Empty(); - } - - ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(resourceType); - IReadOnlyCollection fieldSet = _sparseFieldSetCache.GetSparseFieldSetForSerializer(resourceContext); - - return fieldSet.OfType().ToArray(); - } - - /// - /// - /// Note: this method does NOT check if a relationship is included to determine if it should be serialized. This is because completely hiding a - /// relationship is not the same as not including. In the case of the latter, we may still want to add the relationship to expose the navigation link to - /// the client. - /// - public IReadOnlyCollection GetRelationships(Type resourceType) - { - ArgumentGuard.NotNull(resourceType, nameof(resourceType)); - - if (!ShouldSerialize) - { - return Array.Empty(); - } - - ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(resourceType); - return resourceContext.Relationships; - } - - /// - public void ResetCache() - { - _sparseFieldSetCache.Reset(); - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/FingerprintGenerator.cs b/src/JsonApiDotNetCore/Serialization/FingerprintGenerator.cs deleted file mode 100644 index 3c1083dfbe..0000000000 --- a/src/JsonApiDotNetCore/Serialization/FingerprintGenerator.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Security.Cryptography; -using System.Text; - -namespace JsonApiDotNetCore.Serialization -{ - /// - internal sealed class FingerprintGenerator : IFingerprintGenerator - { - private static readonly byte[] Separator = Encoding.UTF8.GetBytes("|"); - private static readonly uint[] LookupTable = Enumerable.Range(0, 256).Select(ToLookupEntry).ToArray(); - - private static uint ToLookupEntry(int index) - { - string hex = index.ToString("X2"); - return hex[0] + ((uint)hex[1] << 16); - } - - /// - public string Generate(IEnumerable elements) - { - ArgumentGuard.NotNull(elements, nameof(elements)); - - using var hasher = IncrementalHash.CreateHash(HashAlgorithmName.MD5); - - foreach (string element in elements) - { - byte[] buffer = Encoding.UTF8.GetBytes(element); - hasher.AppendData(buffer); - hasher.AppendData(Separator); - } - - byte[] hash = hasher.GetHashAndReset(); - return ByteArrayToHex(hash); - } - - private static string ByteArrayToHex(byte[] bytes) - { - // https://stackoverflow.com/questions/311165/how-do-you-convert-a-byte-array-to-a-hexadecimal-string-and-vice-versa - - char[] buffer = new char[bytes.Length * 2]; - - for (int index = 0; index < bytes.Length; index++) - { - uint value = LookupTable[bytes[index]]; - buffer[2 * index] = (char)value; - buffer[2 * index + 1] = (char)(value >> 16); - } - - return new string(buffer); - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/IETagGenerator.cs b/src/JsonApiDotNetCore/Serialization/IETagGenerator.cs deleted file mode 100644 index 5aa3abf759..0000000000 --- a/src/JsonApiDotNetCore/Serialization/IETagGenerator.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Microsoft.Net.Http.Headers; - -namespace JsonApiDotNetCore.Serialization -{ - /// - /// Provides generation of an ETag HTTP response header. - /// - public interface IETagGenerator - { - /// - /// Generates an ETag HTTP response header value for the response to an incoming request. - /// - /// - /// The incoming request URL, including query string. - /// - /// - /// The produced response body. - /// - /// - /// The ETag, or null to disable saving bandwidth. - /// - public EntityTagHeaderValue Generate(string requestUrl, string responseBody); - } -} diff --git a/src/JsonApiDotNetCore/Serialization/IFieldsToSerialize.cs b/src/JsonApiDotNetCore/Serialization/IFieldsToSerialize.cs deleted file mode 100644 index 682301b040..0000000000 --- a/src/JsonApiDotNetCore/Serialization/IFieldsToSerialize.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; -using System.Collections.Generic; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCore.Serialization -{ - /// - /// Responsible for getting the set of fields that are to be included for a given type in the serialization result. Typically combines various sources of - /// information, like application-wide and request-wide sparse fieldsets. - /// - public interface IFieldsToSerialize - { - /// - /// Indicates whether attributes and relationships should be serialized, based on the current endpoint. - /// - bool ShouldSerialize { get; } - - /// - /// Gets the collection of attributes that are to be serialized for resources of type . - /// - IReadOnlyCollection GetAttributes(Type resourceType); - - /// - /// Gets the collection of relationships that are to be serialized for resources of type . - /// - IReadOnlyCollection GetRelationships(Type resourceType); - - /// - /// Clears internal caches. - /// - void ResetCache(); - } -} diff --git a/src/JsonApiDotNetCore/Serialization/IFingerprintGenerator.cs b/src/JsonApiDotNetCore/Serialization/IFingerprintGenerator.cs deleted file mode 100644 index 51fafaf650..0000000000 --- a/src/JsonApiDotNetCore/Serialization/IFingerprintGenerator.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Collections.Generic; -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Serialization -{ - /// - /// Provides a method to generate a fingerprint for a collection of string values. - /// - [PublicAPI] - public interface IFingerprintGenerator - { - /// - /// Generates a fingerprint for the specified elements. - /// - public string Generate(IEnumerable elements); - } -} diff --git a/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs b/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs deleted file mode 100644 index 3c363cbd4b..0000000000 --- a/src/JsonApiDotNetCore/Serialization/IJsonApiDeserializer.cs +++ /dev/null @@ -1,19 +0,0 @@ -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Serialization -{ - /// - /// Deserializer used internally in JsonApiDotNetCore to deserialize requests. - /// - public interface IJsonApiDeserializer - { - /// - /// Deserializes JSON into a or and constructs resources from - /// . - /// - /// - /// The JSON to be deserialized. - /// - object Deserialize(string body); - } -} diff --git a/src/JsonApiDotNetCore/Serialization/IJsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/IJsonApiReader.cs deleted file mode 100644 index dbc851a492..0000000000 --- a/src/JsonApiDotNetCore/Serialization/IJsonApiReader.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Threading.Tasks; -using JetBrains.Annotations; -using Microsoft.AspNetCore.Mvc.Formatters; - -namespace JsonApiDotNetCore.Serialization -{ - /// - /// The deserializer of the body, used in ASP.NET Core internally to process `FromBody`. - /// - [PublicAPI] - public interface IJsonApiReader - { - Task ReadAsync(InputFormatterContext context); - } -} diff --git a/src/JsonApiDotNetCore/Serialization/IJsonApiSerializer.cs b/src/JsonApiDotNetCore/Serialization/IJsonApiSerializer.cs deleted file mode 100644 index 97f0a15747..0000000000 --- a/src/JsonApiDotNetCore/Serialization/IJsonApiSerializer.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace JsonApiDotNetCore.Serialization -{ - /// - /// Serializer used internally in JsonApiDotNetCore to serialize responses. - /// - public interface IJsonApiSerializer - { - /// - /// Gets the Content-Type HTTP header value. - /// - string ContentType { get; } - - /// - /// Serializes a single resource or a collection of resources. - /// - string Serialize(object content); - } -} diff --git a/src/JsonApiDotNetCore/Serialization/IJsonApiSerializerFactory.cs b/src/JsonApiDotNetCore/Serialization/IJsonApiSerializerFactory.cs deleted file mode 100644 index 38796a596e..0000000000 --- a/src/JsonApiDotNetCore/Serialization/IJsonApiSerializerFactory.cs +++ /dev/null @@ -1,13 +0,0 @@ -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Serialization -{ - [PublicAPI] - public interface IJsonApiSerializerFactory - { - /// - /// Instantiates the serializer to process the servers response. - /// - IJsonApiSerializer GetSerializer(); - } -} diff --git a/src/JsonApiDotNetCore/Serialization/IJsonApiWriter.cs b/src/JsonApiDotNetCore/Serialization/IJsonApiWriter.cs deleted file mode 100644 index ac29395115..0000000000 --- a/src/JsonApiDotNetCore/Serialization/IJsonApiWriter.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Threading.Tasks; -using JetBrains.Annotations; -using Microsoft.AspNetCore.Mvc.Formatters; - -namespace JsonApiDotNetCore.Serialization -{ - [PublicAPI] - public interface IJsonApiWriter - { - Task WriteAsync(OutputFormatterWriteContext context); - } -} diff --git a/src/JsonApiDotNetCore/Serialization/IResponseMeta.cs b/src/JsonApiDotNetCore/Serialization/IResponseMeta.cs deleted file mode 100644 index 2561da2543..0000000000 --- a/src/JsonApiDotNetCore/Serialization/IResponseMeta.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCore.Serialization -{ - /// - /// Provides a method to obtain global JSON:API meta, which is added at top-level to a response . Use - /// to specify nested metadata per individual resource. - /// - public interface IResponseMeta - { - /// - /// Gets the global top-level JSON:API meta information to add to the response. - /// - IReadOnlyDictionary GetMeta(); - } -} diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs deleted file mode 100644 index bcf0e5bcaa..0000000000 --- a/src/JsonApiDotNetCore/Serialization/JsonApiReader.cs +++ /dev/null @@ -1,273 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Extensions; -using Microsoft.AspNetCore.Mvc.Formatters; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCore.Serialization -{ - /// - [PublicAPI] - public class JsonApiReader : IJsonApiReader - { - private readonly IJsonApiDeserializer _deserializer; - private readonly IJsonApiRequest _request; - private readonly IResourceContextProvider _resourceContextProvider; - private readonly TraceLogWriter _traceWriter; - - public JsonApiReader(IJsonApiDeserializer deserializer, IJsonApiRequest request, IResourceContextProvider resourceContextProvider, - ILoggerFactory loggerFactory) - { - ArgumentGuard.NotNull(deserializer, nameof(deserializer)); - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider)); - ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); - - _deserializer = deserializer; - _request = request; - _resourceContextProvider = resourceContextProvider; - _traceWriter = new TraceLogWriter(loggerFactory); - } - - public async Task ReadAsync(InputFormatterContext context) - { - ArgumentGuard.NotNull(context, nameof(context)); - - string body = await GetRequestBodyAsync(context.HttpContext.Request.Body); - - string url = context.HttpContext.Request.GetEncodedUrl(); - _traceWriter.LogMessage(() => $"Received request at '{url}' with body: <<{body}>>"); - - object model = null; - - if (!string.IsNullOrWhiteSpace(body)) - { - try - { - model = _deserializer.Deserialize(body); - } - catch (JsonApiSerializationException exception) - { - throw ToInvalidRequestBodyException(exception, body); - } -#pragma warning disable AV1210 // Catch a specific exception instead of Exception, SystemException or ApplicationException - catch (Exception exception) -#pragma warning restore AV1210 // Catch a specific exception instead of Exception, SystemException or ApplicationException - { - throw new InvalidRequestBodyException(null, null, body, exception); - } - } - - if (_request.Kind == EndpointKind.AtomicOperations) - { - AssertHasRequestBody(model, body); - } - else if (RequiresRequestBody(context.HttpContext.Request.Method)) - { - ValidateRequestBody(model, body, context.HttpContext.Request); - } - - return await InputFormatterResult.SuccessAsync(model); - } - - private async Task GetRequestBodyAsync(Stream bodyStream) - { - using var reader = new StreamReader(bodyStream, leaveOpen: true); - return await reader.ReadToEndAsync(); - } - - private InvalidRequestBodyException ToInvalidRequestBodyException(JsonApiSerializationException exception, string body) - { - if (_request.Kind != EndpointKind.AtomicOperations) - { - return new InvalidRequestBodyException(exception.GenericMessage, exception.SpecificMessage, body, exception); - } - - // In contrast to resource endpoints, we don't include the request body for operations because they are usually very long. - var requestException = new InvalidRequestBodyException(exception.GenericMessage, exception.SpecificMessage, null, exception.InnerException); - - if (exception.AtomicOperationIndex != null) - { - foreach (Error error in requestException.Errors) - { - error.Source.Pointer = $"/atomic:operations[{exception.AtomicOperationIndex}]"; - } - } - - return requestException; - } - - private bool RequiresRequestBody(string requestMethod) - { - if (requestMethod == HttpMethods.Post || requestMethod == HttpMethods.Patch) - { - return true; - } - - return requestMethod == HttpMethods.Delete && _request.Kind == EndpointKind.Relationship; - } - - private void ValidateRequestBody(object model, string body, HttpRequest httpRequest) - { - AssertHasRequestBody(model, body); - - ValidateIncomingResourceType(model, httpRequest); - - if (httpRequest.Method != HttpMethods.Post || _request.Kind == EndpointKind.Relationship) - { - ValidateRequestIncludesId(model, body); - ValidatePrimaryIdValue(model, httpRequest.Path); - } - - if (_request.Kind == EndpointKind.Relationship) - { - ValidateForRelationshipType(httpRequest.Method, model, body); - } - } - - [AssertionMethod] - private static void AssertHasRequestBody(object model, string body) - { - if (model == null && string.IsNullOrWhiteSpace(body)) - { - throw new JsonApiException(new Error(HttpStatusCode.BadRequest) - { - Title = "Missing request body." - }); - } - } - - private void ValidateIncomingResourceType(object model, HttpRequest httpRequest) - { - Type endpointResourceType = GetResourceTypeFromEndpoint(); - - if (endpointResourceType == null) - { - return; - } - - IEnumerable bodyResourceTypes = GetResourceTypesFromRequestBody(model); - - foreach (Type bodyResourceType in bodyResourceTypes) - { - if (!endpointResourceType.IsAssignableFrom(bodyResourceType)) - { - ResourceContext resourceFromEndpoint = _resourceContextProvider.GetResourceContext(endpointResourceType); - ResourceContext resourceFromBody = _resourceContextProvider.GetResourceContext(bodyResourceType); - - throw new ResourceTypeMismatchException(new HttpMethod(httpRequest.Method), httpRequest.Path, resourceFromEndpoint, resourceFromBody); - } - } - } - - private Type GetResourceTypeFromEndpoint() - { - return _request.Kind == EndpointKind.Primary ? _request.PrimaryResource.ResourceType : _request.SecondaryResource?.ResourceType; - } - - private IEnumerable GetResourceTypesFromRequestBody(object model) - { - if (model is IEnumerable resourceCollection) - { - return resourceCollection.Select(resource => resource.GetType()).Distinct(); - } - - return model == null ? Enumerable.Empty() : model.GetType().AsEnumerable(); - } - - private void ValidateRequestIncludesId(object model, string body) - { - bool hasMissingId = model is IEnumerable list ? HasMissingId(list) : HasMissingId(model); - - if (hasMissingId) - { - throw new InvalidRequestBodyException("Request body must include 'id' element.", null, body); - } - } - - private void ValidatePrimaryIdValue(object model, PathString requestPath) - { - if (_request.Kind == EndpointKind.Primary) - { - if (TryGetId(model, out string bodyId) && bodyId != _request.PrimaryId) - { - throw new ResourceIdMismatchException(bodyId, _request.PrimaryId, requestPath); - } - } - } - - /// - /// Checks if the deserialized request body has an ID included. - /// - private bool HasMissingId(object model) - { - return TryGetId(model, out string id) && id == null; - } - - /// - /// Checks if all elements in the deserialized request body have an ID included. - /// - private bool HasMissingId(IEnumerable models) - { - foreach (object model in models) - { - if (TryGetId(model, out string id) && id == null) - { - return true; - } - } - - return false; - } - - private static bool TryGetId(object model, out string id) - { - if (model is IIdentifiable identifiable) - { - id = identifiable.StringId; - return true; - } - - id = null; - return false; - } - - [AssertionMethod] - private void ValidateForRelationshipType(string requestMethod, object model, string body) - { - if (_request.Relationship is HasOneAttribute) - { - if (requestMethod == HttpMethods.Post || requestMethod == HttpMethods.Delete) - { - throw new ToManyRelationshipRequiredException(_request.Relationship.PublicName); - } - - if (model != null && !(model is IIdentifiable)) - { - throw new InvalidRequestBodyException("Expected single data element for to-one relationship.", - $"Expected single data element for '{_request.Relationship.PublicName}' relationship.", body); - } - } - - if (_request.Relationship is HasManyAttribute && !(model is IEnumerable)) - { - throw new InvalidRequestBodyException("Expected data[] element for to-many relationship.", - $"Expected data[] element for '{_request.Relationship.PublicName}' relationship.", body); - } - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiSerializationException.cs b/src/JsonApiDotNetCore/Serialization/JsonApiSerializationException.cs deleted file mode 100644 index 189f2ede64..0000000000 --- a/src/JsonApiDotNetCore/Serialization/JsonApiSerializationException.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Serialization -{ - /// - /// The error that is thrown when (de)serialization of a JSON:API body fails. - /// - [PublicAPI] - public class JsonApiSerializationException : Exception - { - public string GenericMessage { get; } - public string SpecificMessage { get; } - public int? AtomicOperationIndex { get; } - - public JsonApiSerializationException(string genericMessage, string specificMessage, Exception innerException = null, int? atomicOperationIndex = null) - : base(genericMessage, innerException) - { - GenericMessage = genericMessage; - SpecificMessage = specificMessage; - AtomicOperationIndex = atomicOperationIndex; - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/JsonApiWriter.cs b/src/JsonApiDotNetCore/Serialization/JsonApiWriter.cs deleted file mode 100644 index fe5463def9..0000000000 --- a/src/JsonApiDotNetCore/Serialization/JsonApiWriter.cs +++ /dev/null @@ -1,179 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Net; -using System.Net.Http; -using System.Text; -using System.Threading.Tasks; -using JetBrains.Annotations; -using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Extensions; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Formatters; -using Microsoft.Extensions.Logging; -using Microsoft.Net.Http.Headers; - -namespace JsonApiDotNetCore.Serialization -{ - /// - /// Formats the response data used (see https://docs.microsoft.com/en-us/aspnet/core/web-api/advanced/formatting?view=aspnetcore-3.0). It was intended to - /// have as little dependencies as possible in formatting layer for greater extensibility. - /// - [PublicAPI] - public class JsonApiWriter : IJsonApiWriter - { - private readonly IJsonApiSerializer _serializer; - private readonly IExceptionHandler _exceptionHandler; - private readonly IETagGenerator _eTagGenerator; - private readonly TraceLogWriter _traceWriter; - - public JsonApiWriter(IJsonApiSerializer serializer, IExceptionHandler exceptionHandler, IETagGenerator eTagGenerator, ILoggerFactory loggerFactory) - { - ArgumentGuard.NotNull(serializer, nameof(serializer)); - ArgumentGuard.NotNull(exceptionHandler, nameof(exceptionHandler)); - ArgumentGuard.NotNull(eTagGenerator, nameof(eTagGenerator)); - ArgumentGuard.NotNull(loggerFactory, nameof(loggerFactory)); - - _serializer = serializer; - _exceptionHandler = exceptionHandler; - _eTagGenerator = eTagGenerator; - _traceWriter = new TraceLogWriter(loggerFactory); - } - - public async Task WriteAsync(OutputFormatterWriteContext context) - { - ArgumentGuard.NotNull(context, nameof(context)); - - HttpRequest request = context.HttpContext.Request; - HttpResponse response = context.HttpContext.Response; - - await using TextWriter writer = context.WriterFactory(response.Body, Encoding.UTF8); - string responseContent; - - try - { - responseContent = SerializeResponse(context.Object, (HttpStatusCode)response.StatusCode); - } -#pragma warning disable AV1210 // Catch a specific exception instead of Exception, SystemException or ApplicationException - catch (Exception exception) -#pragma warning restore AV1210 // Catch a specific exception instead of Exception, SystemException or ApplicationException - { - ErrorDocument errorDocument = _exceptionHandler.HandleException(exception); - responseContent = _serializer.Serialize(errorDocument); - - response.StatusCode = (int)errorDocument.GetErrorStatusCode(); - } - - bool hasMatchingETag = SetETagResponseHeader(request, response, responseContent); - - if (hasMatchingETag) - { - response.StatusCode = (int)HttpStatusCode.NotModified; - responseContent = string.Empty; - } - - if (request.Method == HttpMethod.Head.Method) - { - responseContent = string.Empty; - } - - string url = request.GetEncodedUrl(); - - if (!string.IsNullOrEmpty(responseContent)) - { - response.ContentType = _serializer.ContentType; - } - - _traceWriter.LogMessage(() => $"Sending {response.StatusCode} response for {request.Method} request at '{url}' with body: <<{responseContent}>>"); - - await writer.WriteAsync(responseContent); - await writer.FlushAsync(); - } - - private string SerializeResponse(object contextObject, HttpStatusCode statusCode) - { - if (contextObject is ProblemDetails problemDetails) - { - throw new UnsuccessfulActionResultException(problemDetails); - } - - if (contextObject == null) - { - if (!IsSuccessStatusCode(statusCode)) - { - throw new UnsuccessfulActionResultException(statusCode); - } - - if (statusCode == HttpStatusCode.NoContent || statusCode == HttpStatusCode.ResetContent || statusCode == HttpStatusCode.NotModified) - { - // Prevent exception from Kestrel server, caused by writing data:null json response. - return null; - } - } - - object contextObjectWrapped = WrapErrors(contextObject); - - return _serializer.Serialize(contextObjectWrapped); - } - - private bool IsSuccessStatusCode(HttpStatusCode statusCode) - { - return new HttpResponseMessage(statusCode).IsSuccessStatusCode; - } - - private static object WrapErrors(object contextObject) - { - if (contextObject is IEnumerable errors) - { - return new ErrorDocument(errors); - } - - if (contextObject is Error error) - { - return new ErrorDocument(error); - } - - return contextObject; - } - - private bool SetETagResponseHeader(HttpRequest request, HttpResponse response, string responseContent) - { - bool isReadOnly = request.Method == HttpMethod.Get.Method || request.Method == HttpMethod.Head.Method; - - if (isReadOnly && response.StatusCode == (int)HttpStatusCode.OK) - { - string url = request.GetEncodedUrl(); - EntityTagHeaderValue responseETag = _eTagGenerator.Generate(url, responseContent); - - if (responseETag != null) - { - response.Headers.Add(HeaderNames.ETag, responseETag.ToString()); - - return RequestContainsMatchingETag(request.Headers, responseETag); - } - } - - return false; - } - - private static bool RequestContainsMatchingETag(IHeaderDictionary requestHeaders, EntityTagHeaderValue responseETag) - { - if (requestHeaders.Keys.Contains(HeaderNames.IfNoneMatch) && - EntityTagHeaderValue.TryParseList(requestHeaders[HeaderNames.IfNoneMatch], out IList requestETags)) - { - foreach (EntityTagHeaderValue requestETag in requestETags) - { - if (responseETag.Equals(requestETag)) - { - return true; - } - } - } - - return false; - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/JsonObjectConverter.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/JsonObjectConverter.cs new file mode 100644 index 0000000000..054fc28e55 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/JsonObjectConverter.cs @@ -0,0 +1,38 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace JsonApiDotNetCore.Serialization.JsonConverters; + +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); + } + + return JsonSerializer.Deserialize(ref reader, options); + } + + 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); + } + else + { + JsonSerializer.Serialize(writer, value, options); + } + } + + protected static JsonException GetEndOfStreamError() + { + return new JsonException("Unexpected end of JSON stream."); + } +} diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs new file mode 100644 index 0000000000..d20bdd5f0d --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs @@ -0,0 +1,396 @@ +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; +using JsonApiDotNetCore.Serialization.Request; + +namespace JsonApiDotNetCore.Serialization.JsonConverters; + +/// +/// Converts to/from JSON. +/// +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public class ResourceObjectConverter : JsonObjectConverter +{ + private static readonly JsonEncodedText TypeText = JsonEncodedText.Encode("type"); + private static readonly JsonEncodedText IdText = JsonEncodedText.Encode("id"); + private static readonly JsonEncodedText LidText = JsonEncodedText.Encode("lid"); + private static readonly JsonEncodedText MetaText = JsonEncodedText.Encode("meta"); + private static readonly JsonEncodedText AttributesText = JsonEncodedText.Encode("attributes"); + private static readonly JsonEncodedText RelationshipsText = JsonEncodedText.Encode("relationships"); + private static readonly JsonEncodedText LinksText = JsonEncodedText.Encode("links"); + + private readonly IResourceGraph _resourceGraph; + + public ResourceObjectConverter(IResourceGraph resourceGraph) + { + ArgumentNullException.ThrowIfNull(resourceGraph); + + _resourceGraph = resourceGraph; + } + + /// + /// Resolves the resource type and attributes against the resource graph. Because attribute values in are typed as + /// , we must lookup and supply the target type to the serializer. + /// + public override ResourceObject Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + // Inside a JsonConverter there is no way to know where in the JSON object tree we are. And the serializer is unable to provide + // the correct position either. So we avoid an exception on missing/invalid 'type' element and postpone producing an error response + // to the post-processing phase. + + var resourceObject = new ResourceObject + { + // The 'attributes' or 'relationships' element may occur before 'type', but we need to know the resource type + // before we can deserialize attributes/relationships into their corresponding CLR types. + Type = PeekType(reader) + }; + + ResourceType? resourceType = resourceObject.Type != null ? _resourceGraph.FindResourceType(resourceObject.Type) : null; + + while (reader.Read()) + { + switch (reader.TokenType) + { + case JsonTokenType.EndObject: + { + return resourceObject; + } + case JsonTokenType.PropertyName: + { + string? propertyName = reader.GetString(); + reader.Read(); + + switch (propertyName) + { + case "id": + { + if (reader.TokenType != JsonTokenType.String) + { + // Newtonsoft.Json used to auto-convert number to strings, while System.Text.Json does not. This is so likely + // to hit users during upgrade that we special-case for this and produce a helpful error message. + var jsonElement = ReadSubTree(ref reader, options); + throw new JsonException($"Failed to convert ID '{jsonElement}' of type '{jsonElement.ValueKind}' to type 'String'."); + } + + resourceObject.Id = reader.GetString(); + break; + } + case "lid": + { + resourceObject.Lid = reader.GetString(); + break; + } + case "attributes": + { + if (resourceType != null) + { + resourceObject.Attributes = ReadAttributes(ref reader, options, resourceType); + } + else + { + reader.Skip(); + } + + break; + } + case "relationships": + { + if (resourceType != null) + { + resourceObject.Relationships = ReadRelationships(ref reader, options, resourceType); + } + else + { + reader.Skip(); + } + + break; + } + case "links": + { + resourceObject.Links = ReadSubTree(ref reader, options); + break; + } + case "meta": + { + resourceObject.Meta = ReadSubTree>(ref reader, options); + break; + } + default: + { + reader.Skip(); + break; + } + } + + break; + } + } + } + + throw GetEndOfStreamError(); + } + + private static string? PeekType(Utf8JsonReader 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 (reader.Read()) + { + if (reader.TokenType == JsonTokenType.PropertyName) + { + string? propertyName = reader.GetString(); + reader.Read(); + + switch (propertyName) + { + case "type": + { + return reader.GetString(); + } + default: + { + reader.Skip(); + break; + } + } + } + } + + return null; + } + + private Dictionary ReadAttributes(ref Utf8JsonReader reader, JsonSerializerOptions options, ResourceType resourceType) + { + var attributes = new Dictionary(); + + while (reader.Read()) + { + switch (reader.TokenType) + { + case JsonTokenType.EndObject: + { + return attributes; + } + case JsonTokenType.PropertyName: + { + 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; + + if (property != null) + { + object? attributeValue; + + if (property.Name == nameof(Identifiable.Id)) + { + attributeValue = JsonInvalidAttributeInfo.Id; + } + else + { + try + { + attributeValue = JsonSerializer.Deserialize(ref reader, property.PropertyType, options); + } + catch (JsonException) + { + // Inside a JsonConverter there is no way to know where in the JSON object tree we are. And the serializer + // is unable to provide the correct position either. So we avoid an exception and postpone producing an error + // response to the post-processing phase, by setting a sentinel value. + var jsonElement = ReadSubTree(ref reader, options); + + attributeValue = new JsonInvalidAttributeInfo(attributeName, property.PropertyType, jsonElement.ToString(), + jsonElement.ValueKind); + } + } + + attributes[attributeName] = attributeValue; + } + else + { + 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; + } + } + } + + 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); + + if (value.Id != null) + { + writer.WriteString(IdText, value.Id); + } + + if (value.Lid != null) + { + writer.WriteString(LidText, value.Lid); + } + + if (!value.Attributes.IsNullOrEmpty()) + { + writer.WritePropertyName(AttributesText); + 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); + 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()) + { + writer.WritePropertyName(LinksText); + WriteSubTree(writer, value.Links, options); + } + + if (!value.Meta.IsNullOrEmpty()) + { + writer.WritePropertyName(MetaText); + WriteSubTree(writer, value.Meta, options); + } + + 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 new file mode 100644 index 0000000000..81ae41a380 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/SingleOrManyDataConverterFactory.cs @@ -0,0 +1,84 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.JsonConverters; + +/// +/// Converts to/from JSON. +/// +[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)!; + } + + private sealed class SingleOrManyDataConverter : JsonObjectConverter> + where T : ResourceIdentifierObject, new() + { + public override SingleOrManyData Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + List objects = []; + bool isManyData = false; + bool hasCompletedToMany = false; + + do + { + switch (reader.TokenType) + { + case JsonTokenType.EndArray: + { + hasCompletedToMany = true; + break; + } + case JsonTokenType.Null: + { + if (isManyData) + { + objects.Add(new T()); + } + + break; + } + case JsonTokenType.StartObject: + { + var resourceObject = ReadSubTree(ref reader, options); + objects.Add(resourceObject); + break; + } + case JsonTokenType.StartArray: + { + isManyData = true; + break; + } + } + } + while (isManyData && !hasCompletedToMany && reader.Read()); + + object? data = isManyData ? objects : objects.FirstOrDefault(); + return new SingleOrManyData(data); + } + + public override void Write(Utf8JsonWriter writer, SingleOrManyData value, JsonSerializerOptions options) + { + WriteSubTree(writer, value.Value, options); + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyDocumentConverter.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyDocumentConverter.cs new file mode 100644 index 0000000000..f459b49c9b --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyDocumentConverter.cs @@ -0,0 +1,111 @@ +using System.Text.Json; +using JetBrains.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.JsonConverters; + +/// +/// Converts to JSON. +/// +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class WriteOnlyDocumentConverter : JsonObjectConverter +{ + private static readonly JsonEncodedText JsonApiText = JsonEncodedText.Encode("jsonapi"); + private static readonly JsonEncodedText LinksText = JsonEncodedText.Encode("links"); + private static readonly JsonEncodedText DataText = JsonEncodedText.Encode("data"); + private static readonly JsonEncodedText AtomicOperationsText = JsonEncodedText.Encode("atomic:operations"); + private static readonly JsonEncodedText AtomicResultsText = JsonEncodedText.Encode("atomic:results"); + private static readonly JsonEncodedText ErrorsText = JsonEncodedText.Encode("errors"); + 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 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) + { + writer.WritePropertyName(JsonApiText); + WriteSubTree(writer, value.JsonApi, options); + } + + if (value.Links != null && value.Links.HasValue()) + { + writer.WritePropertyName(LinksText); + WriteSubTree(writer, value.Links, options); + } + + if (value.Data.IsAssigned) + { + writer.WritePropertyName(DataText); + WriteSubTree(writer, value.Data, options); + } + + if (!value.Operations.IsNullOrEmpty()) + { + writer.WritePropertyName(AtomicOperationsText); + WriteSubTree(writer, value.Operations, options); + } + + if (!value.Results.IsNullOrEmpty()) + { + writer.WritePropertyName(AtomicResultsText); + 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()) + { + writer.WritePropertyName(ErrorsText); + WriteSubTree(writer, value.Errors, options); + } + + if (value.Included != null) + { + writer.WritePropertyName(IncludedText); + WriteSubTree(writer, value.Included, options); + } + + if (!value.Meta.IsNullOrEmpty()) + { + writer.WritePropertyName(MetaText); + WriteSubTree(writer, value.Meta, options); + } + + writer.WriteEndObject(); + } +} diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyRelationshipObjectConverter.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyRelationshipObjectConverter.cs new file mode 100644 index 0000000000..db6b7dc686 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/WriteOnlyRelationshipObjectConverter.cs @@ -0,0 +1,55 @@ +using System.Text.Json; +using JetBrains.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.JsonConverters; + +/// +/// Converts to JSON. +/// +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class WriteOnlyRelationshipObjectConverter : JsonObjectConverter +{ + private static readonly JsonEncodedText DataText = JsonEncodedText.Encode("data"); + private static readonly JsonEncodedText LinksText = JsonEncodedText.Encode("links"); + private static readonly JsonEncodedText MetaText = JsonEncodedText.Encode("meta"); + + /// + /// 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 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()) + { + writer.WritePropertyName(LinksText); + WriteSubTree(writer, value.Links, options); + } + + if (value.Data.IsAssigned) + { + writer.WritePropertyName(DataText); + WriteSubTree(writer, value.Data, options); + } + + if (!value.Meta.IsNullOrEmpty()) + { + writer.WritePropertyName(MetaText); + WriteSubTree(writer, value.Meta, options); + } + + writer.WriteEndObject(); + } +} diff --git a/src/JsonApiDotNetCore/Serialization/JsonSerializerExtensions.cs b/src/JsonApiDotNetCore/Serialization/JsonSerializerExtensions.cs deleted file mode 100644 index e08b9c3ce0..0000000000 --- a/src/JsonApiDotNetCore/Serialization/JsonSerializerExtensions.cs +++ /dev/null @@ -1,55 +0,0 @@ -using Newtonsoft.Json; -using Newtonsoft.Json.Serialization; - -namespace JsonApiDotNetCore.Serialization -{ - internal static class JsonSerializerExtensions - { - public static void ApplyErrorSettings(this JsonSerializer jsonSerializer) - { - jsonSerializer.NullValueHandling = NullValueHandling.Ignore; - - // JsonSerializer.Create() only performs a shallow copy of the shared settings, so we cannot change properties on its ContractResolver. - // But to serialize ErrorMeta.Data correctly, we need to ensure that JsonSerializer.ContractResolver.NamingStrategy.ProcessExtensionDataNames - // is set to 'true' while serializing errors. - var sharedContractResolver = (DefaultContractResolver)jsonSerializer.ContractResolver; - - jsonSerializer.ContractResolver = new DefaultContractResolver - { - NamingStrategy = new AlwaysProcessExtensionDataNamingStrategyWrapper(sharedContractResolver.NamingStrategy) - }; - } - - private sealed class AlwaysProcessExtensionDataNamingStrategyWrapper : NamingStrategy - { - private readonly NamingStrategy _namingStrategy; - - public AlwaysProcessExtensionDataNamingStrategyWrapper(NamingStrategy namingStrategy) - { - _namingStrategy = namingStrategy ?? new DefaultNamingStrategy(); - } - - public override string GetExtensionDataName(string name) - { - // Ignore the value of ProcessExtensionDataNames property on the wrapped strategy (short-circuit). - return ResolvePropertyName(name); - } - - public override string GetDictionaryKey(string key) - { - // Ignore the value of ProcessDictionaryKeys property on the wrapped strategy (short-circuit). - return ResolvePropertyName(key); - } - - public override string GetPropertyName(string name, bool hasSpecifiedName) - { - return _namingStrategy.GetPropertyName(name, hasSpecifiedName); - } - - protected override string ResolvePropertyName(string name) - { - return _namingStrategy.GetPropertyName(name, false); - } - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationCode.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationCode.cs index f7852773b3..7fa05cac3d 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationCode.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationCode.cs @@ -1,16 +1,14 @@ -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; +using System.Text.Json.Serialization; -namespace JsonApiDotNetCore.Serialization.Objects +namespace JsonApiDotNetCore.Serialization.Objects; + +/// +/// See "op" in https://jsonapi.org/ext/atomic/#operation-objects. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum AtomicOperationCode { - /// - /// See https://jsonapi.org/ext/atomic/#operation-objects. - /// - [JsonConverter(typeof(StringEnumConverter))] - public enum AtomicOperationCode - { - Add, - Update, - Remove - } + Add, + Update, + Remove } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs index b6b4a134b8..b9233e0ed8 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationObject.cs @@ -1,25 +1,31 @@ -using System.Collections.Generic; -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; +using System.Text.Json.Serialization; +using JetBrains.Annotations; -namespace JsonApiDotNetCore.Serialization.Objects +namespace JsonApiDotNetCore.Serialization.Objects; + +/// +/// See https://jsonapi.org/ext/atomic/#operation-objects. +/// +[PublicAPI] +public sealed class AtomicOperationObject { - /// - /// See https://jsonapi.org/ext/atomic/#operation-objects. - /// - public sealed class AtomicOperationObject : ExposableData - { - [JsonProperty("meta", NullValueHandling = NullValueHandling.Ignore)] - public Dictionary Meta { get; set; } + [JsonPropertyName("data")] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public SingleOrManyData Data { get; set; } + + [JsonPropertyName("op")] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public AtomicOperationCode Code { get; set; } - [JsonProperty("op")] - [JsonConverter(typeof(StringEnumConverter))] - public AtomicOperationCode Code { get; set; } + [JsonPropertyName("ref")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public AtomicReference? Ref { get; set; } - [JsonProperty("ref", NullValueHandling = NullValueHandling.Ignore)] - public AtomicReference Ref { get; set; } + [JsonPropertyName("href")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Href { get; set; } - [JsonProperty("href", NullValueHandling = NullValueHandling.Ignore)] - public string Href { get; set; } - } + [JsonPropertyName("meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? Meta { get; set; } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationsDocument.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationsDocument.cs deleted file mode 100644 index b7352ed4c6..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Objects/AtomicOperationsDocument.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Collections.Generic; -using Newtonsoft.Json; - -namespace JsonApiDotNetCore.Serialization.Objects -{ - /// - /// See https://jsonapi.org/ext/atomic/#document-structure. - /// - public sealed class AtomicOperationsDocument - { - /// - /// See "meta" in https://jsonapi.org/format/#document-top-level. - /// - [JsonProperty("meta", NullValueHandling = NullValueHandling.Ignore)] - public IDictionary Meta { get; set; } - - /// - /// See "jsonapi" in https://jsonapi.org/format/#document-top-level. - /// - [JsonProperty("jsonapi", NullValueHandling = NullValueHandling.Ignore)] - public JsonApiObject JsonApi { get; set; } - - /// - /// See "links" in https://jsonapi.org/format/#document-top-level. - /// - [JsonProperty("links", NullValueHandling = NullValueHandling.Ignore)] - public TopLevelLinks Links { get; set; } - - /// - /// See https://jsonapi.org/ext/atomic/#operation-objects. - /// - [JsonProperty("atomic:operations", NullValueHandling = NullValueHandling.Ignore)] - public IList Operations { get; set; } - - /// - /// See https://jsonapi.org/ext/atomic/#result-objects. - /// - [JsonProperty("atomic:results", NullValueHandling = NullValueHandling.Ignore)] - public IList Results { get; set; } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicReference.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicReference.cs index 5847d33fe9..fcc56298c1 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/AtomicReference.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicReference.cs @@ -1,20 +1,15 @@ -using System.Text; -using Newtonsoft.Json; +using System.Text.Json.Serialization; +using JetBrains.Annotations; -namespace JsonApiDotNetCore.Serialization.Objects -{ - /// - /// See 'ref' in https://jsonapi.org/ext/atomic/#operation-objects. - /// - public sealed class AtomicReference : ResourceIdentifierObject - { - [JsonProperty("relationship", NullValueHandling = NullValueHandling.Ignore)] - public string Relationship { get; set; } +namespace JsonApiDotNetCore.Serialization.Objects; - protected override void WriteMembers(StringBuilder builder) - { - base.WriteMembers(builder); - WriteMember(builder, "relationship", Relationship); - } - } +/// +/// See "ref" in https://jsonapi.org/ext/atomic/#operation-objects. +/// +[PublicAPI] +public sealed class AtomicReference : ResourceIdentity +{ + [JsonPropertyName("relationship")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Relationship { get; set; } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/AtomicResultObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/AtomicResultObject.cs index 44b5f691d7..90a4ee9345 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/AtomicResultObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/AtomicResultObject.cs @@ -1,14 +1,19 @@ -using System.Collections.Generic; -using Newtonsoft.Json; +using System.Text.Json.Serialization; +using JetBrains.Annotations; -namespace JsonApiDotNetCore.Serialization.Objects +namespace JsonApiDotNetCore.Serialization.Objects; + +/// +/// See https://jsonapi.org/ext/atomic/#result-objects. +/// +[PublicAPI] +public sealed class AtomicResultObject { - /// - /// See https://jsonapi.org/ext/atomic/#result-objects. - /// - public sealed class AtomicResultObject : ExposableData - { - [JsonProperty("meta", NullValueHandling = NullValueHandling.Ignore)] - public Dictionary Meta { get; set; } - } + [JsonPropertyName("data")] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public SingleOrManyData Data { get; set; } + + [JsonPropertyName("meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? Meta { get; set; } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/Document.cs b/src/JsonApiDotNetCore/Serialization/Objects/Document.cs index 56c79f2b86..f21334f5c4 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/Document.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/Document.cs @@ -1,35 +1,43 @@ -using System.Collections.Generic; -using Newtonsoft.Json; +using System.Text.Json.Serialization; +using JetBrains.Annotations; -namespace JsonApiDotNetCore.Serialization.Objects +namespace JsonApiDotNetCore.Serialization.Objects; + +/// +/// See https://jsonapi.org/format#document-top-level and https://jsonapi.org/ext/atomic/#document-structure. +/// +[PublicAPI] +public sealed class Document { - /// - /// https://jsonapi.org/format/#document-structure - /// - public sealed class Document : ExposableData - { - /// - /// see "meta" in https://jsonapi.org/format/#document-top-level - /// - [JsonProperty("meta", NullValueHandling = NullValueHandling.Ignore)] - public IDictionary Meta { get; set; } - - /// - /// see "jsonapi" in https://jsonapi.org/format/#document-top-level - /// - [JsonProperty("jsonapi", NullValueHandling = NullValueHandling.Ignore)] - public JsonApiObject JsonApi { get; set; } - - /// - /// see "links" in https://jsonapi.org/format/#document-top-level - /// - [JsonProperty("links", NullValueHandling = NullValueHandling.Ignore)] - public TopLevelLinks Links { get; set; } - - /// - /// see "included" in https://jsonapi.org/format/#document-top-level - /// - [JsonProperty("included", NullValueHandling = NullValueHandling.Ignore, Order = 1)] - public IList Included { get; set; } - } + [JsonPropertyName("jsonapi")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonApiObject? JsonApi { get; set; } + + [JsonPropertyName("links")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public TopLevelLinks? Links { get; set; } + + [JsonPropertyName("data")] + // JsonIgnoreCondition is determined at runtime by WriteOnlyDocumentConverter. + public SingleOrManyData Data { get; set; } + + [JsonPropertyName("atomic:operations")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList? Operations { get; set; } + + [JsonPropertyName("atomic:results")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList? Results { get; set; } + + [JsonPropertyName("errors")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList? Errors { get; set; } + + [JsonPropertyName("included")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IList? Included { get; set; } + + [JsonPropertyName("meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? Meta { get; set; } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/Error.cs b/src/JsonApiDotNetCore/Serialization/Objects/Error.cs deleted file mode 100644 index b33f79bba8..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Objects/Error.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System; -using System.Linq; -using System.Net; -using JetBrains.Annotations; -using Newtonsoft.Json; - -namespace JsonApiDotNetCore.Serialization.Objects -{ - /// - /// Provides additional information about a problem encountered while performing an operation. Error objects MUST be returned as an array keyed by errors - /// in the top level of a JSON:API document. - /// - [PublicAPI] - public sealed class Error - { - /// - /// A unique identifier for this particular occurrence of the problem. - /// - [JsonProperty] - public string Id { get; set; } = Guid.NewGuid().ToString(); - - /// - /// A link that leads to further details about this particular occurrence of the problem. - /// - [JsonProperty] - public ErrorLinks Links { get; set; } = new ErrorLinks(); - - /// - /// The HTTP status code applicable to this problem. - /// - [JsonIgnore] - public HttpStatusCode StatusCode { get; set; } - - [JsonProperty] - public string Status - { - get => StatusCode.ToString("d"); - set => StatusCode = (HttpStatusCode)int.Parse(value); - } - - /// - /// An application-specific error code. - /// - [JsonProperty] - public string Code { get; set; } - - /// - /// A short, human-readable summary of the problem that SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of - /// localization. - /// - [JsonProperty] - public string Title { get; set; } - - /// - /// A human-readable explanation specific to this occurrence of the problem. Like title, this field's value can be localized. - /// - [JsonProperty] - public string Detail { get; set; } - - /// - /// An object containing references to the source of the error. - /// - [JsonProperty] - public ErrorSource Source { get; set; } = new ErrorSource(); - - /// - /// An object containing non-standard meta-information (key/value pairs) about the error. - /// - [JsonProperty] - public ErrorMeta Meta { get; set; } = new ErrorMeta(); - - public Error(HttpStatusCode statusCode) - { - StatusCode = statusCode; - } - - public bool ShouldSerializeLinks() - { - return Links?.About != null; - } - - public bool ShouldSerializeSource() - { - return Source != null && (Source.Pointer != null || Source.Parameter != null); - } - - public bool ShouldSerializeMeta() - { - return Meta != null && Meta.Data.Any(); - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ErrorDocument.cs b/src/JsonApiDotNetCore/Serialization/Objects/ErrorDocument.cs deleted file mode 100644 index 971fdecce3..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Objects/ErrorDocument.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using JetBrains.Annotations; - -namespace JsonApiDotNetCore.Serialization.Objects -{ - [PublicAPI] - public sealed class ErrorDocument - { - public IReadOnlyList Errors { get; } - - public ErrorDocument() - : this(Array.Empty()) - { - } - - public ErrorDocument(Error error) - : this(error.AsEnumerable()) - { - } - - public ErrorDocument(IEnumerable errors) - { - ArgumentGuard.NotNull(errors, nameof(errors)); - - Errors = errors.ToList(); - } - - public HttpStatusCode GetErrorStatusCode() - { - int[] statusCodes = Errors.Select(error => (int)error.StatusCode).Distinct().ToArray(); - - if (statusCodes.Length == 1) - { - return (HttpStatusCode)statusCodes[0]; - } - - int statusCode = int.Parse(statusCodes.Max().ToString()[0] + "00"); - return (HttpStatusCode)statusCode; - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ErrorLinks.cs b/src/JsonApiDotNetCore/Serialization/Objects/ErrorLinks.cs index 16be6392e1..6ba2c2a6f6 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ErrorLinks.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ErrorLinks.cs @@ -1,13 +1,19 @@ -using Newtonsoft.Json; +using System.Text.Json.Serialization; +using JetBrains.Annotations; -namespace JsonApiDotNetCore.Serialization.Objects +namespace JsonApiDotNetCore.Serialization.Objects; + +/// +/// See "links" in https://jsonapi.org/format/#error-objects. +/// +[PublicAPI] +public sealed class ErrorLinks { - public sealed class ErrorLinks - { - /// - /// A URL that leads to further details about this particular occurrence of the problem. - /// - [JsonProperty] - public string About { get; set; } - } + [JsonPropertyName("about")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? About { get; set; } + + [JsonPropertyName("type")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Type { get; set; } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ErrorMeta.cs b/src/JsonApiDotNetCore/Serialization/Objects/ErrorMeta.cs deleted file mode 100644 index 1589089719..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Objects/ErrorMeta.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using System.Collections.Generic; -using JetBrains.Annotations; -using Newtonsoft.Json; - -namespace JsonApiDotNetCore.Serialization.Objects -{ - /// - /// A meta object containing non-standard meta-information about the error. - /// - [PublicAPI] - public sealed class ErrorMeta - { - [JsonExtensionData] - public IDictionary Data { get; } = new Dictionary(); - - public void IncludeExceptionStackTrace(Exception exception) - { - if (exception == null) - { - Data.Remove("StackTrace"); - } - else - { - Data["StackTrace"] = exception.ToString().Split("\n", int.MaxValue, StringSplitOptions.RemoveEmptyEntries); - } - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ErrorObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/ErrorObject.cs new file mode 100644 index 0000000000..63d95174cd --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Objects/ErrorObject.cs @@ -0,0 +1,69 @@ +using System.Net; +using System.Text.Json.Serialization; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Serialization.Objects; + +/// +/// See https://jsonapi.org/format/#error-objects. +/// +[PublicAPI] +public sealed class ErrorObject(HttpStatusCode statusCode) +{ + [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Id { get; set; } = Guid.NewGuid().ToString(); + + [JsonPropertyName("links")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ErrorLinks? Links { get; set; } + + [JsonIgnore] + public HttpStatusCode StatusCode { get; set; } = statusCode; + + [JsonPropertyName("status")] + [JsonIgnore(Condition = JsonIgnoreCondition.Never)] + public string Status + { + get => StatusCode.ToString("d"); + set => StatusCode = (HttpStatusCode)int.Parse(value); + } + + [JsonPropertyName("code")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Code { get; set; } + + [JsonPropertyName("title")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; set; } + + [JsonPropertyName("detail")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Detail { get; set; } + + [JsonPropertyName("source")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ErrorSource? Source { get; set; } + + [JsonPropertyName("meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? Meta { get; set; } + + public static HttpStatusCode GetResponseStatusCode(IReadOnlyList errorObjects) + { + if (errorObjects.IsNullOrEmpty()) + { + return HttpStatusCode.InternalServerError; + } + + int[] statusCodes = errorObjects.Select(error => (int)error.StatusCode).Distinct().ToArray(); + + if (statusCodes.Length == 1) + { + return (HttpStatusCode)statusCodes[0]; + } + + int statusCode = int.Parse($"{statusCodes.Max().ToString()[0]}00"); + return (HttpStatusCode)statusCode; + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ErrorSource.cs b/src/JsonApiDotNetCore/Serialization/Objects/ErrorSource.cs index 88cdc1812d..b9242895f4 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ErrorSource.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ErrorSource.cs @@ -1,20 +1,23 @@ -using Newtonsoft.Json; +using System.Text.Json.Serialization; +using JetBrains.Annotations; -namespace JsonApiDotNetCore.Serialization.Objects +namespace JsonApiDotNetCore.Serialization.Objects; + +/// +/// See "source" in https://jsonapi.org/format/#error-objects. +/// +[PublicAPI] +public sealed class ErrorSource { - public sealed class ErrorSource - { - /// - /// Optional. A JSON Pointer [RFC6901] to the associated resource in the request document [e.g. "/data" for a primary data object, or - /// "/data/attributes/title" for a specific attribute]. - /// - [JsonProperty] - public string Pointer { get; set; } + [JsonPropertyName("pointer")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Pointer { get; set; } + + [JsonPropertyName("parameter")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Parameter { get; set; } - /// - /// Optional. A string indicating which URI query parameter caused the error. - /// - [JsonProperty] - public string Parameter { get; set; } - } + [JsonPropertyName("header")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Header { get; set; } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ExposableData.cs b/src/JsonApiDotNetCore/Serialization/Objects/ExposableData.cs deleted file mode 100644 index 27ef8d0690..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Objects/ExposableData.cs +++ /dev/null @@ -1,113 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using JetBrains.Annotations; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -namespace JsonApiDotNetCore.Serialization.Objects -{ - [PublicAPI] - public abstract class ExposableData - where TResource : class - { - private bool IsEmpty => !HasManyData && SingleData == null; - - private bool HasManyData => IsManyData && ManyData.Any(); - - /// - /// Internally used to indicate if the document's primary data should still be serialized when it's value is null. This is used when a single resource is - /// requested but not present (eg /articles/1/author). - /// - internal bool IsPopulated { get; private set; } - - internal bool HasResource => IsPopulated && !IsEmpty; - - /// - /// See "primary data" in https://jsonapi.org/format/#document-top-level. - /// - [JsonProperty("data")] - public object Data - { - get => GetPrimaryData(); - set => SetPrimaryData(value); - } - - /// - /// Internally used for "single" primary data. - /// - [JsonIgnore] - public TResource SingleData { get; private set; } - - /// - /// Internally used for "many" primary data. - /// - [JsonIgnore] - public IList ManyData { get; private set; } - - /// - /// Indicates if the document's primary data is "single" or "many". - /// - [JsonIgnore] - public bool IsManyData { get; private set; } - - /// - /// See https://www.newtonsoft.com/json/help/html/ConditionalProperties.htm. - /// - /// - /// Moving this method to the derived class where it is needed only in the case of would make more sense, but Newtonsoft - /// does not support this. - /// - public bool ShouldSerializeData() - { - if (GetType() == typeof(RelationshipEntry)) - { - return IsPopulated; - } - - return true; - } - - /// - /// Gets the "single" or "many" data depending on which one was assigned in this document. - /// - protected object GetPrimaryData() - { - if (IsManyData) - { - return ManyData; - } - - return SingleData; - } - - /// - /// Sets the primary data depending on if it is "single" or "many" data. - /// - protected void SetPrimaryData(object value) - { - IsPopulated = true; - - if (value is JObject jObject) - { - SingleData = jObject.ToObject(); - } - else if (value is TResource ro) - { - SingleData = ro; - } - else if (value != null) - { - IsManyData = true; - - if (value is JArray jArray) - { - ManyData = jArray.ToObject>(); - } - else - { - ManyData = (List)value; - } - } - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Objects/JsonApiObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/JsonApiObject.cs deleted file mode 100644 index 66682fb6a2..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Objects/JsonApiObject.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.Collections.Generic; -using Newtonsoft.Json; - -namespace JsonApiDotNetCore.Serialization.Objects -{ - /// - /// https://jsonapi.org/format/1.1/#document-jsonapi-object. - /// - public sealed class JsonApiObject - { - [JsonProperty("version", NullValueHandling = NullValueHandling.Ignore)] - public string Version { get; set; } - - [JsonProperty("ext", NullValueHandling = NullValueHandling.Ignore)] - public ICollection Ext { get; set; } - - [JsonProperty("profile", NullValueHandling = NullValueHandling.Ignore)] - public ICollection Profile { get; set; } - - /// - /// see "meta" in https://jsonapi.org/format/1.1/#document-meta - /// - [JsonProperty("meta", NullValueHandling = NullValueHandling.Ignore)] - 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/RelationshipEntry.cs b/src/JsonApiDotNetCore/Serialization/Objects/RelationshipEntry.cs deleted file mode 100644 index 8ebbbf1b16..0000000000 --- a/src/JsonApiDotNetCore/Serialization/Objects/RelationshipEntry.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Collections.Generic; -using Newtonsoft.Json; - -namespace JsonApiDotNetCore.Serialization.Objects -{ - public sealed class RelationshipEntry : ExposableData - { - [JsonProperty("links", NullValueHandling = NullValueHandling.Ignore)] - public RelationshipLinks Links { get; set; } - - [JsonProperty("meta", NullValueHandling = NullValueHandling.Ignore)] - public IDictionary Meta { get; set; } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Objects/RelationshipLinks.cs b/src/JsonApiDotNetCore/Serialization/Objects/RelationshipLinks.cs index e0ee4c680f..4c1095e1a7 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/RelationshipLinks.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/RelationshipLinks.cs @@ -1,24 +1,24 @@ -using Newtonsoft.Json; +using System.Text.Json.Serialization; +using JetBrains.Annotations; -namespace JsonApiDotNetCore.Serialization.Objects +namespace JsonApiDotNetCore.Serialization.Objects; + +/// +/// See "links" in https://jsonapi.org/format/#document-resource-object-relationships. +/// +[PublicAPI] +public sealed class RelationshipLinks { - public sealed class RelationshipLinks - { - /// - /// See "links" bulletin at https://jsonapi.org/format/#document-resource-object-relationships. - /// - [JsonProperty("self", NullValueHandling = NullValueHandling.Ignore)] - public string Self { get; set; } + [JsonPropertyName("self")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Self { get; set; } - /// - /// See https://jsonapi.org/format/#document-resource-object-related-resource-links. - /// - [JsonProperty("related", NullValueHandling = NullValueHandling.Ignore)] - public string Related { get; set; } + [JsonPropertyName("related")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Related { get; set; } - internal bool HasValue() - { - return !string.IsNullOrEmpty(Self) || !string.IsNullOrEmpty(Related); - } + internal bool HasValue() + { + return !string.IsNullOrEmpty(Self) || !string.IsNullOrEmpty(Related); } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/RelationshipObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/RelationshipObject.cs new file mode 100644 index 0000000000..c677e9a0fb --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Objects/RelationshipObject.cs @@ -0,0 +1,23 @@ +using System.Text.Json.Serialization; +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Serialization.Objects; + +/// +/// See https://jsonapi.org/format/#document-resource-object-relationships. +/// +[PublicAPI] +public sealed class RelationshipObject +{ + [JsonPropertyName("links")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public RelationshipLinks? Links { get; set; } + + [JsonPropertyName("data")] + // JsonIgnoreCondition is determined at runtime by WriteOnlyRelationshipObjectConverter. + public SingleOrManyData Data { get; set; } + + [JsonPropertyName("meta")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? Meta { get; set; } +} diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs index 672255d96e..e82ebe16bf 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ResourceIdentifierObject.cs @@ -1,50 +1,16 @@ -using System.Text; -using Newtonsoft.Json; +using System.Text.Json.Serialization; +using JetBrains.Annotations; -namespace JsonApiDotNetCore.Serialization.Objects -{ - public class ResourceIdentifierObject - { - [JsonProperty("type", Order = -4)] - public string Type { get; set; } - - [JsonProperty("id", NullValueHandling = NullValueHandling.Ignore, Order = -3)] - public string Id { get; set; } - - [JsonProperty("lid", NullValueHandling = NullValueHandling.Ignore, Order = -2)] - public string Lid { get; set; } - - public override string ToString() - { - var builder = new StringBuilder(); - - WriteMembers(builder); - builder.Insert(0, GetType().Name + ": "); +namespace JsonApiDotNetCore.Serialization.Objects; - return builder.ToString(); - } - - protected virtual void WriteMembers(StringBuilder builder) - { - WriteMember(builder, "type", Type); - WriteMember(builder, "id", Id); - WriteMember(builder, "lid", Lid); - } - - protected static void WriteMember(StringBuilder builder, string memberName, string memberValue) - { - if (memberValue != null) - { - if (builder.Length > 0) - { - builder.Append(", "); - } - - builder.Append(memberName); - builder.Append("=\""); - builder.Append(memberValue); - builder.Append('"'); - } - } - } +/// +/// See https://jsonapi.org/format/#document-resource-identifier-objects. +/// +[PublicAPI] +public class ResourceIdentifierObject : ResourceIdentity +{ + [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 3afbd3ffbd..6f749cca88 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ResourceLinks.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ResourceLinks.cs @@ -1,18 +1,20 @@ -using Newtonsoft.Json; +using System.Text.Json.Serialization; +using JetBrains.Annotations; -namespace JsonApiDotNetCore.Serialization.Objects +namespace JsonApiDotNetCore.Serialization.Objects; + +/// +/// See https://jsonapi.org/format/#document-resource-object-links. +/// +[PublicAPI] +public sealed class ResourceLinks { - public sealed class ResourceLinks - { - /// - /// See https://jsonapi.org/format/#document-resource-object-links. - /// - [JsonProperty("self", NullValueHandling = NullValueHandling.Ignore)] - public string Self { get; set; } + [JsonPropertyName("self")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Self { get; set; } - internal bool HasValue() - { - return !string.IsNullOrEmpty(Self); - } + internal bool HasValue() + { + return !string.IsNullOrEmpty(Self); } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ResourceObject.cs b/src/JsonApiDotNetCore/Serialization/Objects/ResourceObject.cs index c3ae877787..fc1ff3d146 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ResourceObject.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ResourceObject.cs @@ -1,20 +1,26 @@ -using System.Collections.Generic; -using Newtonsoft.Json; +using System.Text.Json.Serialization; +using JetBrains.Annotations; -namespace JsonApiDotNetCore.Serialization.Objects -{ - public sealed class ResourceObject : ResourceIdentifierObject - { - [JsonProperty("attributes", NullValueHandling = NullValueHandling.Ignore)] - public IDictionary Attributes { get; set; } +namespace JsonApiDotNetCore.Serialization.Objects; - [JsonProperty("relationships", NullValueHandling = NullValueHandling.Ignore)] - public IDictionary Relationships { get; set; } +/// +/// See https://jsonapi.org/format/#document-resource-objects. +/// +[PublicAPI] +public sealed class ResourceObject : ResourceIdentifierObject +{ + [JsonPropertyName("attributes")] + [JsonPropertyOrder(1)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? Attributes { get; set; } - [JsonProperty("links", NullValueHandling = NullValueHandling.Ignore)] - public ResourceLinks Links { get; set; } + [JsonPropertyName("relationships")] + [JsonPropertyOrder(2)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public IDictionary? Relationships { get; set; } - [JsonProperty("meta", NullValueHandling = NullValueHandling.Ignore)] - public IDictionary Meta { get; set; } - } + [JsonPropertyName("links")] + [JsonPropertyOrder(3)] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ResourceLinks? Links { get; set; } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/SingleOrManyData.cs b/src/JsonApiDotNetCore/Serialization/Objects/SingleOrManyData.cs new file mode 100644 index 0000000000..99884d61e6 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Objects/SingleOrManyData.cs @@ -0,0 +1,47 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.Serialization.JsonConverters; + +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 : ResourceIdentifierObject, new() +{ + public object? Value => ManyValue != null ? ManyValue : SingleValue; + + [JsonIgnore] + public bool IsAssigned { get; } + + [JsonIgnore] + public T? SingleValue { get; } + + [JsonIgnore] + public IList? ManyValue { get; } + + public SingleOrManyData(object? value) + { + IsAssigned = true; + + if (value is IEnumerable manyData) + { + ManyValue = manyData.ToList(); + SingleValue = null; + } + else + { + ManyValue = null; + SingleValue = (T?)value; + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Objects/TopLevelLinks.cs b/src/JsonApiDotNetCore/Serialization/Objects/TopLevelLinks.cs index 6970eecb8a..f83510fb23 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/TopLevelLinks.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/TopLevelLinks.cs @@ -1,73 +1,45 @@ -using Newtonsoft.Json; +using System.Text.Json.Serialization; +using JetBrains.Annotations; -namespace JsonApiDotNetCore.Serialization.Objects -{ - /// - /// See links section in https://jsonapi.org/format/#document-top-level. - /// - public sealed class TopLevelLinks - { - [JsonProperty("self")] - public string Self { get; set; } - - [JsonProperty("related")] - public string Related { get; set; } - - [JsonProperty("describedby")] - public string DescribedBy { get; set; } - - [JsonProperty("first")] - public string First { get; set; } +namespace JsonApiDotNetCore.Serialization.Objects; - [JsonProperty("last")] - public string Last { get; set; } - - [JsonProperty("prev")] - public string Prev { get; set; } - - [JsonProperty("next")] - public string Next { get; set; } - - // http://www.newtonsoft.com/json/help/html/ConditionalProperties.htm - public bool ShouldSerializeSelf() - { - return !string.IsNullOrEmpty(Self); - } +/// +/// See "links" in https://jsonapi.org/format/#document-top-level. +/// +[PublicAPI] +public sealed class TopLevelLinks +{ + [JsonPropertyName("self")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Self { get; set; } - public bool ShouldSerializeRelated() - { - return !string.IsNullOrEmpty(Related); - } + [JsonPropertyName("related")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Related { get; set; } - public bool ShouldSerializeDescribedBy() - { - return !string.IsNullOrEmpty(DescribedBy); - } + [JsonPropertyName("describedby")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DescribedBy { get; set; } - public bool ShouldSerializeFirst() - { - return !string.IsNullOrEmpty(First); - } + [JsonPropertyName("first")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? First { get; set; } - public bool ShouldSerializeLast() - { - return !string.IsNullOrEmpty(Last); - } + [JsonPropertyName("last")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Last { get; set; } - public bool ShouldSerializePrev() - { - return !string.IsNullOrEmpty(Prev); - } + [JsonPropertyName("prev")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Prev { get; set; } - public bool ShouldSerializeNext() - { - return !string.IsNullOrEmpty(Next); - } + [JsonPropertyName("next")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Next { get; set; } - internal bool HasValue() - { - return ShouldSerializeSelf() || ShouldSerializeRelated() || ShouldSerializeDescribedBy() || ShouldSerializeFirst() || ShouldSerializeLast() || - ShouldSerializePrev() || ShouldSerializeNext(); - } + internal bool HasValue() + { + return !string.IsNullOrEmpty(Self) || !string.IsNullOrEmpty(Related) || !string.IsNullOrEmpty(DescribedBy) || !string.IsNullOrEmpty(First) || + !string.IsNullOrEmpty(Last) || !string.IsNullOrEmpty(Prev) || !string.IsNullOrEmpty(Next); } } diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs new file mode 100644 index 0000000000..4339cf6c48 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicOperationObjectAdapter.cs @@ -0,0 +1,162 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +public sealed class AtomicOperationObjectAdapter : IAtomicOperationObjectAdapter +{ + private readonly IResourceDataInOperationsRequestAdapter _resourceDataInOperationsRequestAdapter; + private readonly IAtomicReferenceAdapter _atomicReferenceAdapter; + private readonly IRelationshipDataAdapter _relationshipDataAdapter; + private readonly IJsonApiOptions _options; + + public AtomicOperationObjectAdapter(IJsonApiOptions options, IAtomicReferenceAdapter atomicReferenceAdapter, + IResourceDataInOperationsRequestAdapter resourceDataInOperationsRequestAdapter, IRelationshipDataAdapter relationshipDataAdapter) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(atomicReferenceAdapter); + ArgumentNullException.ThrowIfNull(resourceDataInOperationsRequestAdapter); + ArgumentNullException.ThrowIfNull(relationshipDataAdapter); + + _options = options; + _atomicReferenceAdapter = atomicReferenceAdapter; + _resourceDataInOperationsRequestAdapter = resourceDataInOperationsRequestAdapter; + _relationshipDataAdapter = relationshipDataAdapter; + } + + /// + public OperationContainer Convert(AtomicOperationObject atomicOperationObject, RequestAdapterState state) + { + ArgumentNullException.ThrowIfNull(atomicOperationObject); + ArgumentNullException.ThrowIfNull(state); + + AssertNoHref(atomicOperationObject, state); + + WriteOperationKind writeOperation = ConvertOperationCode(atomicOperationObject, state); + + state.WritableTargetedFields = new TargetedFields(); + + state.WritableRequest = new JsonApiRequest + { + Kind = EndpointKind.AtomicOperations, + WriteOperation = writeOperation + }; + + (ResourceIdentityRequirements requirements, IIdentifiable? primaryResource) = ConvertRef(atomicOperationObject, state); + + if (writeOperation is WriteOperationKind.CreateResource or WriteOperationKind.UpdateResource) + { + primaryResource = _resourceDataInOperationsRequestAdapter.Convert(atomicOperationObject.Data, requirements, state); + } + + return new OperationContainer(primaryResource!, state.WritableTargetedFields, state.Request); + } + + private static void AssertNoHref(AtomicOperationObject atomicOperationObject, RequestAdapterState state) + { + if (atomicOperationObject.Href != null) + { + using IDisposable _ = state.Position.PushElement("href"); + throw new ModelConversionException(state.Position, "The 'href' element is not supported.", null); + } + } + + private WriteOperationKind ConvertOperationCode(AtomicOperationObject atomicOperationObject, RequestAdapterState state) + { + switch (atomicOperationObject.Code) + { + case AtomicOperationCode.Add: + { + if (atomicOperationObject.Ref is { Relationship: null }) + { + using IDisposable _ = state.Position.PushElement("ref"); + throw new ModelConversionException(state.Position, "The 'relationship' element is required.", null); + } + + return atomicOperationObject.Ref == null ? WriteOperationKind.CreateResource : WriteOperationKind.AddToRelationship; + } + case AtomicOperationCode.Update: + { + return atomicOperationObject.Ref?.Relationship != null ? WriteOperationKind.SetRelationship : WriteOperationKind.UpdateResource; + } + case AtomicOperationCode.Remove: + { + if (atomicOperationObject.Ref == null) + { + throw new ModelConversionException(state.Position, "The 'ref' element is required.", null); + } + + return atomicOperationObject.Ref.Relationship != null ? WriteOperationKind.RemoveFromRelationship : WriteOperationKind.DeleteResource; + } + } + + throw new NotSupportedException($"Unknown operation code '{atomicOperationObject.Code}'."); + } + + private (ResourceIdentityRequirements requirements, IIdentifiable? primaryResource) ConvertRef(AtomicOperationObject atomicOperationObject, + RequestAdapterState state) + { + ResourceIdentityRequirements requirements = CreateRefRequirements(state); + IIdentifiable? primaryResource = null; + + AtomicReferenceResult? refResult = atomicOperationObject.Ref != null + ? _atomicReferenceAdapter.Convert(atomicOperationObject.Ref, requirements, state) + : null; + + if (refResult != null) + { + state.WritableRequest!.PrimaryId = refResult.Resource.StringId; + state.WritableRequest.PrimaryResourceType = refResult.ResourceType; + state.WritableRequest.Relationship = refResult.Relationship; + state.WritableRequest.IsCollection = refResult.Relationship is HasManyAttribute; + + ConvertRefRelationship(atomicOperationObject.Data, refResult, state); + + requirements = CreateDataRequirements(refResult, requirements); + primaryResource = refResult.Resource; + } + + return (requirements, primaryResource); + } + + private ResourceIdentityRequirements CreateRefRequirements(RequestAdapterState state) + { + return new ResourceIdentityRequirements + { + EvaluateIdConstraint = resourceType => + ResourceIdentityRequirements.DoEvaluateIdConstraint(resourceType, state.Request.WriteOperation, _options.ClientIdGeneration), + EvaluateAllowLid = resourceType => + ResourceIdentityRequirements.DoEvaluateAllowLid(resourceType, state.Request.WriteOperation, _options.ClientIdGeneration) + }; + } + + private static ResourceIdentityRequirements CreateDataRequirements(AtomicReferenceResult refResult, ResourceIdentityRequirements refRequirements) + { + return new ResourceIdentityRequirements + { + ResourceType = refResult.ResourceType, + EvaluateIdConstraint = refRequirements.EvaluateIdConstraint, + EvaluateAllowLid = refRequirements.EvaluateAllowLid, + IdValue = refResult.Resource.StringId, + LidValue = refResult.Resource.LocalId, + RelationshipName = refResult.Relationship?.PublicName + }; + } + + private void ConvertRefRelationship(SingleOrManyData relationshipData, AtomicReferenceResult refResult, RequestAdapterState state) + { + if (refResult.Relationship != null) + { + state.WritableRequest!.SecondaryResourceType = refResult.Relationship.RightType; + + state.WritableTargetedFields!.Relationships.Add(refResult.Relationship); + + object? rightValue = _relationshipDataAdapter.Convert(relationshipData, refResult.Relationship, true, state); + refResult.Relationship.SetValue(refResult.Resource, rightValue); + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceAdapter.cs new file mode 100644 index 0000000000..0dc72a08fa --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceAdapter.cs @@ -0,0 +1,42 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +[PublicAPI] +public sealed class AtomicReferenceAdapter(IResourceGraph resourceGraph, IResourceFactory resourceFactory) + : ResourceIdentityAdapter(resourceGraph, resourceFactory), IAtomicReferenceAdapter +{ + /// + public AtomicReferenceResult Convert(AtomicReference atomicReference, ResourceIdentityRequirements requirements, RequestAdapterState state) + { + ArgumentNullException.ThrowIfNull(atomicReference); + ArgumentNullException.ThrowIfNull(requirements); + ArgumentNullException.ThrowIfNull(state); + + using IDisposable _ = state.Position.PushElement("ref"); + (IIdentifiable resource, ResourceType resourceType) = ConvertResourceIdentity(atomicReference, requirements, state); + + RelationshipAttribute? relationship = atomicReference.Relationship != null + ? ConvertRelationship(atomicReference.Relationship, resourceType, state) + : null; + + return new AtomicReferenceResult(resource, resourceType, relationship); + } + + private RelationshipAttribute ConvertRelationship(string relationshipName, ResourceType resourceType, RequestAdapterState state) + { + using IDisposable _ = state.Position.PushElement("relationship"); + RelationshipAttribute? relationship = resourceType.FindRelationshipByPublicName(relationshipName); + + 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 new file mode 100644 index 0000000000..367d7ec2ea --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/AtomicReferenceResult.cs @@ -0,0 +1,27 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// The result of validating and converting "ref" in an entry of an atomic:operations request. +/// +[PublicAPI] +public sealed class AtomicReferenceResult +{ + public IIdentifiable Resource { get; } + public ResourceType ResourceType { get; } + public RelationshipAttribute? Relationship { get; } + + public AtomicReferenceResult(IIdentifiable resource, ResourceType resourceType, RelationshipAttribute? relationship) + { + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(resourceType); + + Resource = resource; + ResourceType = resourceType; + Relationship = relationship; + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/BaseAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/BaseAdapter.cs new file mode 100644 index 0000000000..8969d28fd7 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/BaseAdapter.cs @@ -0,0 +1,72 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Serialization.Objects; +using SysNotNull = System.Diagnostics.CodeAnalysis.NotNullAttribute; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// Contains shared assertions for derived types. +/// +public abstract class BaseAdapter +{ + [AssertionMethod] + protected static void AssertHasData(SingleOrManyData data, RequestAdapterState state) + where T : ResourceIdentifierObject, new() + { + ArgumentNullException.ThrowIfNull(state); + + if (!data.IsAssigned) + { + throw new ModelConversionException(state.Position, "The 'data' element is required.", null); + } + } + + [AssertionMethod] + protected static void AssertDataHasSingleValue(SingleOrManyData data, bool allowNull, RequestAdapterState state) + where T : ResourceIdentifierObject, new() + { + ArgumentNullException.ThrowIfNull(state); + + if (data.SingleValue == null) + { + if (!allowNull) + { + if (data.ManyValue == null) + { + AssertObjectIsNotNull(data.SingleValue, state); + } + + throw new ModelConversionException(state.Position, "Expected an object, instead of an array.", null); + } + + if (data.ManyValue != null) + { + throw new ModelConversionException(state.Position, "Expected an object or 'null', instead of an array.", null); + } + } + } + + [AssertionMethod] + protected static void AssertDataHasManyValue(SingleOrManyData data, RequestAdapterState state) + where T : ResourceIdentifierObject, new() + { + ArgumentNullException.ThrowIfNull(state); + + if (data.ManyValue == null) + { + throw new ModelConversionException(state.Position, + data.SingleValue == null ? "Expected an array, instead of 'null'." : "Expected an array, instead of an object.", null); + } + } + + 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 new file mode 100644 index 0000000000..f5b514b202 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentAdapter.cs @@ -0,0 +1,41 @@ +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +public sealed class DocumentAdapter : IDocumentAdapter +{ + private readonly IJsonApiRequest _request; + private readonly ITargetedFields _targetedFields; + private readonly IDocumentInResourceOrRelationshipRequestAdapter _documentInResourceOrRelationshipRequestAdapter; + private readonly IDocumentInOperationsRequestAdapter _documentInOperationsRequestAdapter; + + public DocumentAdapter(IJsonApiRequest request, ITargetedFields targetedFields, + IDocumentInResourceOrRelationshipRequestAdapter documentInResourceOrRelationshipRequestAdapter, + IDocumentInOperationsRequestAdapter documentInOperationsRequestAdapter) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(targetedFields); + ArgumentNullException.ThrowIfNull(documentInResourceOrRelationshipRequestAdapter); + ArgumentNullException.ThrowIfNull(documentInOperationsRequestAdapter); + + _request = request; + _targetedFields = targetedFields; + _documentInResourceOrRelationshipRequestAdapter = documentInResourceOrRelationshipRequestAdapter; + _documentInOperationsRequestAdapter = documentInOperationsRequestAdapter; + } + + /// + public object? Convert(Document document) + { + ArgumentNullException.ThrowIfNull(document); + + using var adapterState = new RequestAdapterState(_request, _targetedFields); + + return adapterState.Request.Kind == EndpointKind.AtomicOperations + ? _documentInOperationsRequestAdapter.Convert(document, adapterState) + : _documentInResourceOrRelationshipRequestAdapter.Convert(document, adapterState); + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInOperationsRequestAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInOperationsRequestAdapter.cs new file mode 100644 index 0000000000..bb75927c92 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInOperationsRequestAdapter.cs @@ -0,0 +1,72 @@ +using System.Diagnostics.CodeAnalysis; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +public sealed class DocumentInOperationsRequestAdapter : BaseAdapter, IDocumentInOperationsRequestAdapter +{ + private readonly IJsonApiOptions _options; + private readonly IAtomicOperationObjectAdapter _atomicOperationObjectAdapter; + + public DocumentInOperationsRequestAdapter(IJsonApiOptions options, IAtomicOperationObjectAdapter atomicOperationObjectAdapter) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(atomicOperationObjectAdapter); + + _options = options; + _atomicOperationObjectAdapter = atomicOperationObjectAdapter; + } + + /// + public IList Convert(Document document, RequestAdapterState state) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(state); + + AssertHasOperations(document.Operations, state); + + using IDisposable _ = state.Position.PushElement("atomic:operations"); + AssertMaxOperationsNotExceeded(document.Operations, state); + + return ConvertOperations(document.Operations, state); + } + + private static void AssertHasOperations([NotNull] IEnumerable? atomicOperationObjects, RequestAdapterState state) + { + if (atomicOperationObjects.IsNullOrEmpty()) + { + throw new ModelConversionException(state.Position, "No operations found.", null); + } + } + + 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}."); + } + } + + private List ConvertOperations(IEnumerable atomicOperationObjects, RequestAdapterState state) + { + List operations = []; + int operationIndex = 0; + + foreach (AtomicOperationObject? atomicOperationObject in atomicOperationObjects) + { + using IDisposable _ = state.Position.PushArrayIndex(operationIndex); + AssertObjectIsNotNull(atomicOperationObject, state); + + OperationContainer operation = _atomicOperationObjectAdapter.Convert(atomicOperationObject, state); + operations.Add(operation); + + operationIndex++; + } + + return operations; + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs new file mode 100644 index 0000000000..48b0c2c5f6 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/DocumentInResourceOrRelationshipRequestAdapter.cs @@ -0,0 +1,76 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +public sealed class DocumentInResourceOrRelationshipRequestAdapter : IDocumentInResourceOrRelationshipRequestAdapter +{ + private readonly IJsonApiOptions _options; + private readonly IResourceDataAdapter _resourceDataAdapter; + private readonly IRelationshipDataAdapter _relationshipDataAdapter; + + public DocumentInResourceOrRelationshipRequestAdapter(IJsonApiOptions options, IResourceDataAdapter resourceDataAdapter, + IRelationshipDataAdapter relationshipDataAdapter) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(resourceDataAdapter); + ArgumentNullException.ThrowIfNull(relationshipDataAdapter); + + _options = options; + _resourceDataAdapter = resourceDataAdapter; + _relationshipDataAdapter = relationshipDataAdapter; + } + + /// + public object? Convert(Document document, RequestAdapterState state) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(state); + + state.WritableTargetedFields = new TargetedFields(); + + switch (state.Request.WriteOperation) + { + case WriteOperationKind.CreateResource: + case WriteOperationKind.UpdateResource: + { + ResourceIdentityRequirements requirements = CreateIdentityRequirements(state); + return _resourceDataAdapter.Convert(document.Data, requirements, state); + } + case WriteOperationKind.SetRelationship: + case WriteOperationKind.AddToRelationship: + case WriteOperationKind.RemoveFromRelationship: + { + if (state.Request.Relationship == null) + { + // Let the controller throw for unknown relationship, because it knows the relationship name that was used. + return new HashSet(IdentifiableComparer.Instance); + } + + 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); + } + } + + return null; + } + + private ResourceIdentityRequirements CreateIdentityRequirements(RequestAdapterState state) + { + var requirements = new ResourceIdentityRequirements + { + ResourceType = state.Request.PrimaryResourceType, + EvaluateIdConstraint = resourceType => + ResourceIdentityRequirements.DoEvaluateIdConstraint(resourceType, state.Request.WriteOperation, _options.ClientIdGeneration), + IdValue = state.Request.PrimaryId + }; + + return requirements; + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IAtomicOperationObjectAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IAtomicOperationObjectAdapter.cs new file mode 100644 index 0000000000..b734a96dad --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IAtomicOperationObjectAdapter.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// Validates and converts a single operation inside an atomic:operations request. +/// +public interface IAtomicOperationObjectAdapter +{ + /// + /// Validates and converts the specified . + /// + OperationContainer Convert(AtomicOperationObject atomicOperationObject, RequestAdapterState state); +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IAtomicReferenceAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IAtomicReferenceAdapter.cs new file mode 100644 index 0000000000..047ec78181 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IAtomicReferenceAdapter.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// Validates and converts a 'ref' element in an entry of an atomic:operations request. It appears in most kinds of operations and typically indicates +/// what would otherwise have been in the endpoint URL, if it were a resource request. +/// +public interface IAtomicReferenceAdapter +{ + /// + /// Validates and converts the specified . + /// + AtomicReferenceResult Convert(AtomicReference atomicReference, ResourceIdentityRequirements requirements, RequestAdapterState state); +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentAdapter.cs new file mode 100644 index 0000000000..5480d41c01 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentAdapter.cs @@ -0,0 +1,37 @@ +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// The entry point for validating and converting the deserialized from the request body into a model. The produced models are +/// used in ASP.NET Model Binding. +/// +public interface IDocumentAdapter +{ + /// + /// Validates and converts the specified . Possible return values: + /// + /// + /// + /// ]]> (operations) + /// + /// + /// + /// + /// ]]> (to-many relationship, unknown relationship) + /// + /// + /// + /// + /// (resource, to-one relationship) + /// + /// + /// + /// + /// (to-one relationship) + /// + /// + /// + /// + object? Convert(Document document); +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInOperationsRequestAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInOperationsRequestAdapter.cs new file mode 100644 index 0000000000..7f6a6935cf --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInOperationsRequestAdapter.cs @@ -0,0 +1,17 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// Validates and converts a belonging to an atomic:operations request. +/// +public interface IDocumentInOperationsRequestAdapter +{ + /// + /// Validates and converts the specified . + /// +#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/IDocumentInResourceOrRelationshipRequestAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInResourceOrRelationshipRequestAdapter.cs new file mode 100644 index 0000000000..5da648ce4e --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IDocumentInResourceOrRelationshipRequestAdapter.cs @@ -0,0 +1,14 @@ +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// Validates and converts a belonging to a resource or relationship request. +/// +public interface IDocumentInResourceOrRelationshipRequestAdapter +{ + /// + /// Validates and converts the specified . + /// + object? Convert(Document document, RequestAdapterState state); +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IRelationshipDataAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IRelationshipDataAdapter.cs new file mode 100644 index 0000000000..4a1b6d11a9 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IRelationshipDataAdapter.cs @@ -0,0 +1,22 @@ +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// Validates and converts the data from a relationship. It appears in a relationship request, in the relationships of a POST/PATCH resource request, in +/// an entry of an atomic:operations request that targets a relationship and in the relationships of an operations entry that creates or updates a +/// resource. +/// +public interface IRelationshipDataAdapter +{ + /// + /// Validates and converts the specified . + /// + object? Convert(SingleOrManyData data, RelationshipAttribute relationship, bool useToManyElementType, RequestAdapterState state); + + /// + /// Validates and converts the specified . + /// + object? Convert(SingleOrManyData data, RelationshipAttribute relationship, bool useToManyElementType, RequestAdapterState state); +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceDataAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceDataAdapter.cs new file mode 100644 index 0000000000..850e81d77c --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceDataAdapter.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// Validates and converts the data from a resource in a POST/PATCH resource request. +/// +public interface IResourceDataAdapter +{ + /// + /// Validates and converts the specified . + /// + IIdentifiable Convert(SingleOrManyData data, ResourceIdentityRequirements requirements, RequestAdapterState state); +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceDataInOperationsRequestAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceDataInOperationsRequestAdapter.cs new file mode 100644 index 0000000000..b2dd3da844 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceDataInOperationsRequestAdapter.cs @@ -0,0 +1,17 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// Validates and converts the data from an entry in an atomic:operations request that creates or updates a resource. +/// +[PublicAPI] +public interface IResourceDataInOperationsRequestAdapter +{ + /// + /// Validates and converts the specified . + /// + IIdentifiable Convert(SingleOrManyData data, ResourceIdentityRequirements requirements, RequestAdapterState state); +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceIdentifierObjectAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceIdentifierObjectAdapter.cs new file mode 100644 index 0000000000..d1b233d4f0 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceIdentifierObjectAdapter.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// Validates and converts a . It appears in the data object(s) of a relationship. +/// +public interface IResourceIdentifierObjectAdapter +{ + /// + /// Validates and converts the specified . + /// + IIdentifiable Convert(ResourceIdentifierObject resourceIdentifierObject, ResourceIdentityRequirements requirements, RequestAdapterState state); +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceObjectAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceObjectAdapter.cs new file mode 100644 index 0000000000..a8bec30bf6 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/IResourceObjectAdapter.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// Validates and converts a . It appears in a POST/PATCH resource request and an entry in an atomic:operations request that +/// creates or updates a resource. +/// +public interface IResourceObjectAdapter +{ + /// + /// Validates and converts the specified . + /// + (IIdentifiable resource, ResourceType resourceType) Convert(ResourceObject resourceObject, ResourceIdentityRequirements requirements, + RequestAdapterState state); +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/JsonElementConstraint.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/JsonElementConstraint.cs new file mode 100644 index 0000000000..eaee05538c --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/JsonElementConstraint.cs @@ -0,0 +1,20 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// Lists constraints for the presence or absence of a JSON element. +/// +[PublicAPI] +public enum JsonElementConstraint +{ + /// + /// A value for the element is not allowed. + /// + Forbidden, + + /// + /// A value for the element is required. + /// + Required +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs new file mode 100644 index 0000000000..a488e727af --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RelationshipDataAdapter.cs @@ -0,0 +1,117 @@ +using System.Collections; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +public sealed class RelationshipDataAdapter : BaseAdapter, IRelationshipDataAdapter +{ + private readonly IResourceIdentifierObjectAdapter _resourceIdentifierObjectAdapter; + + public RelationshipDataAdapter(IResourceIdentifierObjectAdapter resourceIdentifierObjectAdapter) + { + ArgumentNullException.ThrowIfNull(resourceIdentifierObjectAdapter); + + _resourceIdentifierObjectAdapter = resourceIdentifierObjectAdapter; + } + + /// + public object? Convert(SingleOrManyData data, RelationshipAttribute relationship, bool useToManyElementType, RequestAdapterState state) + { + SingleOrManyData identifierData = ToIdentifierData(data); + return Convert(identifierData, relationship, useToManyElementType, state); + } + + private static SingleOrManyData ToIdentifierData(SingleOrManyData data) + { + if (!data.IsAssigned) + { + return default; + } + + object? newValue = null; + + if (data.ManyValue != null) + { + newValue = data.ManyValue.Select(resourceObject => new ResourceIdentifierObject + { + Type = resourceObject.Type, + Id = resourceObject.Id, + Lid = resourceObject.Lid + }); + } + else if (data.SingleValue != null) + { + newValue = new ResourceIdentifierObject + { + Type = data.SingleValue.Type, + Id = data.SingleValue.Id, + Lid = data.SingleValue.Lid + }; + } + + return new SingleOrManyData(newValue); + } + + /// + public object? Convert(SingleOrManyData data, RelationshipAttribute relationship, bool useToManyElementType, + RequestAdapterState state) + { + ArgumentNullException.ThrowIfNull(relationship); + ArgumentNullException.ThrowIfNull(state); + AssertHasData(data, state); + + using IDisposable _ = state.Position.PushElement("data"); + + var requirements = new ResourceIdentityRequirements + { + ResourceType = relationship.RightType, + EvaluateIdConstraint = _ => JsonElementConstraint.Required, + EvaluateAllowLid = _ => state.Request.Kind == EndpointKind.AtomicOperations, + RelationshipName = relationship.PublicName + }; + + return relationship is HasOneAttribute + ? ConvertToOneRelationshipData(data, requirements, state) + : ConvertToManyRelationshipData(data, relationship, requirements, useToManyElementType, state); + } + + private IIdentifiable? ConvertToOneRelationshipData(SingleOrManyData data, ResourceIdentityRequirements requirements, + RequestAdapterState state) + { + AssertDataHasSingleValue(data, true, state); + + return data.SingleValue != null ? _resourceIdentifierObjectAdapter.Convert(data.SingleValue, requirements, state) : null; + } + + private IEnumerable ConvertToManyRelationshipData(SingleOrManyData data, RelationshipAttribute relationship, + ResourceIdentityRequirements requirements, bool useToManyElementType, RequestAdapterState state) + { + AssertDataHasManyValue(data, state); + + int arrayIndex = 0; + List rightResources = []; + + foreach (ResourceIdentifierObject resourceIdentifierObject in data.ManyValue!) + { + using IDisposable _ = state.Position.PushArrayIndex(arrayIndex); + + IIdentifiable rightResource = _resourceIdentifierObjectAdapter.Convert(resourceIdentifierObject, requirements, state); + rightResources.Add(rightResource); + + arrayIndex++; + } + + if (useToManyElementType) + { + return CollectionConverter.Instance.CopyToTypedCollection(rightResources, relationship.Property.PropertyType); + } + + var resourceSet = new HashSet(IdentifiableComparer.Instance); + resourceSet.UnionWith(rightResources); + return resourceSet; + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterPosition.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterPosition.cs new file mode 100644 index 0000000000..da3ef481b5 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterPosition.cs @@ -0,0 +1,69 @@ +using System.Text; +using JetBrains.Annotations; + +#pragma warning disable CA1001 // Types that own disposable fields should be disposable + +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// Tracks the location within an object tree when validating and converting a request body. +/// +[PublicAPI] +public sealed class RequestAdapterPosition +{ + private readonly Stack _stack = new(); + private readonly IDisposable _disposable; + + public RequestAdapterPosition() + { + _disposable = new PopStackOnDispose(this); + } + + public IDisposable PushElement(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + _stack.Push($"/{name}"); + return _disposable; + } + + public IDisposable PushArrayIndex(int index) + { + _stack.Push($"[{index}]"); + return _disposable; + } + + public string? ToSourcePointer() + { + if (_stack.Count == 0) + { + return null; + } + + var builder = new StringBuilder(); + var clone = new Stack(_stack); + + while (clone.Count > 0) + { + string element = clone.Pop(); + builder.Append(element); + } + + return builder.ToString(); + } + + public override string ToString() + { + return ToSourcePointer() ?? string.Empty; + } + + private sealed class PopStackOnDispose(RequestAdapterPosition owner) : IDisposable + { + private readonly RequestAdapterPosition _owner = owner; + + public void Dispose() + { + _owner._stack.Pop(); + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterState.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterState.cs new file mode 100644 index 0000000000..089bd48bdf --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/RequestAdapterState.cs @@ -0,0 +1,66 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// Tracks state while adapting objects from into the shape that controller actions accept. +/// +[PublicAPI] +public sealed class RequestAdapterState : IDisposable +{ + private readonly RevertRequestStateOnDispose? _backupRequestState; + + public IJsonApiRequest InjectableRequest { get; } + public ITargetedFields InjectableTargetedFields { get; } + + public JsonApiRequest? WritableRequest { get; set; } + public TargetedFields? WritableTargetedFields { get; set; } + + public RequestAdapterPosition Position { get; } = new(); + public IJsonApiRequest Request => WritableRequest ?? InjectableRequest; + + public RequestAdapterState(IJsonApiRequest request, ITargetedFields targetedFields) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(targetedFields); + + InjectableRequest = request; + InjectableTargetedFields = targetedFields; + + if (request.Kind == EndpointKind.AtomicOperations) + { + _backupRequestState = new RevertRequestStateOnDispose(request, targetedFields); + } + } + + public void RefreshInjectables() + { + if (WritableRequest != null) + { + InjectableRequest.CopyFrom(WritableRequest); + } + + if (WritableTargetedFields != null) + { + InjectableTargetedFields.CopyFrom(WritableTargetedFields); + } + } + + public void Dispose() + { + // For resource requests, we'd like the injected state to become the final state. + // But for operations, it makes more sense to reset than to reflect the last operation. + + if (_backupRequestState != null) + { + _backupRequestState.Dispose(); + } + else + { + RefreshInjectables(); + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataAdapter.cs new file mode 100644 index 0000000000..91f745327d --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataAdapter.cs @@ -0,0 +1,50 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +public class ResourceDataAdapter : BaseAdapter, IResourceDataAdapter +{ + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; + private readonly IResourceObjectAdapter _resourceObjectAdapter; + + public ResourceDataAdapter(IResourceDefinitionAccessor resourceDefinitionAccessor, IResourceObjectAdapter resourceObjectAdapter) + { + ArgumentNullException.ThrowIfNull(resourceDefinitionAccessor); + ArgumentNullException.ThrowIfNull(resourceObjectAdapter); + + _resourceDefinitionAccessor = resourceDefinitionAccessor; + _resourceObjectAdapter = resourceObjectAdapter; + } + + /// + public IIdentifiable Convert(SingleOrManyData data, ResourceIdentityRequirements requirements, RequestAdapterState state) + { + ArgumentNullException.ThrowIfNull(requirements); + ArgumentNullException.ThrowIfNull(state); + + AssertHasData(data, state); + + using IDisposable _ = state.Position.PushElement("data"); + AssertDataHasSingleValue(data, false, state); + + (IIdentifiable resource, ResourceType _) = ConvertResourceObject(data, requirements, state); + + // Ensure that IResourceDefinition extensibility point sees the current operation, in case it injects IJsonApiRequest. + state.RefreshInjectables(); + + _resourceDefinitionAccessor.OnDeserialize(resource); + return resource; + } + + 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 new file mode 100644 index 0000000000..30deb3c9ba --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceDataInOperationsRequestAdapter.cs @@ -0,0 +1,26 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +public sealed class ResourceDataInOperationsRequestAdapter(IResourceDefinitionAccessor resourceDefinitionAccessor, IResourceObjectAdapter resourceObjectAdapter) + : ResourceDataAdapter(resourceDefinitionAccessor, resourceObjectAdapter), IResourceDataInOperationsRequestAdapter +{ + 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); + + state.WritableRequest!.PrimaryResourceType = resourceType; + state.WritableRequest.PrimaryId = resource.StringId; + + return (resource, resourceType); + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentifierObjectAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentifierObjectAdapter.cs new file mode 100644 index 0000000000..05e2bcdafb --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentifierObjectAdapter.cs @@ -0,0 +1,21 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +public sealed class ResourceIdentifierObjectAdapter(IResourceGraph resourceGraph, IResourceFactory resourceFactory) + : ResourceIdentityAdapter(resourceGraph, resourceFactory), IResourceIdentifierObjectAdapter +{ + /// + public IIdentifiable Convert(ResourceIdentifierObject resourceIdentifierObject, ResourceIdentityRequirements requirements, RequestAdapterState 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 new file mode 100644 index 0000000000..d0def4e9cd --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs @@ -0,0 +1,318 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// Base class for validating and converting objects that represent an identity. +/// +public abstract class ResourceIdentityAdapter : BaseAdapter +{ + private readonly IResourceGraph _resourceGraph; + private readonly IResourceFactory _resourceFactory; + + protected ResourceIdentityAdapter(IResourceGraph resourceGraph, IResourceFactory resourceFactory) + { + ArgumentNullException.ThrowIfNull(resourceGraph); + ArgumentNullException.ThrowIfNull(resourceFactory); + + _resourceGraph = resourceGraph; + _resourceFactory = resourceFactory; + } + + protected (IIdentifiable resource, ResourceType resourceType) ConvertResourceIdentity(ResourceIdentity identity, ResourceIdentityRequirements requirements, + RequestAdapterState state) + { + ArgumentNullException.ThrowIfNull(identity); + ArgumentNullException.ThrowIfNull(requirements); + ArgumentNullException.ThrowIfNull(state); + + ResourceType resourceType = ResolveType(identity, requirements, state); + IIdentifiable resource = CreateResource(identity, requirements, resourceType, state); + + return (resource, resourceType); + } + + private ResourceType ResolveType(ResourceIdentity identity, ResourceIdentityRequirements requirements, RequestAdapterState state) + { + AssertHasType(identity.Type, state); + + using IDisposable _ = state.Position.PushElement("type"); + ResourceType? resourceType = _resourceGraph.FindResourceType(identity.Type); + + AssertIsKnownResourceType(resourceType, identity.Type, state); + + if (state.Request.WriteOperation is WriteOperationKind.CreateResource or WriteOperationKind.UpdateResource) + { + AssertIsNotAbstractType(resourceType, identity.Type, state); + } + + AssertIsCompatibleResourceType(resourceType, requirements.ResourceType, requirements.RelationshipName, state); + + return resourceType; + } + + private static void AssertHasType([NotNull] string? identityType, RequestAdapterState state) + { + if (identityType == null) + { + throw new ModelConversionException(state.Position, "The 'type' element is required.", null); + } + } + + private static void AssertIsKnownResourceType([NotNull] ResourceType? resourceType, string typeName, RequestAdapterState state) + { + if (resourceType == null) + { + throw new ModelConversionException(state.Position, "Unknown resource type found.", $"Resource type '{typeName}' does not exist."); + } + } + + private static void AssertIsNotAbstractType(ResourceType resourceType, string typeName, RequestAdapterState state) + { + if (resourceType.ClrType.IsAbstract) + { + throw new ModelConversionException(state.Position, "Abstract resource type found.", $"Resource type '{typeName}' is abstract."); + } + } + + private static void AssertIsCompatibleResourceType(ResourceType actual, ResourceType? expected, string? relationshipName, RequestAdapterState state) + { + if (expected != null && !expected.ClrType.IsAssignableFrom(actual.ClrType)) + { + string message = relationshipName != null + ? $"Type '{actual.PublicName}' is not convertible to type '{expected.PublicName}' of relationship '{relationshipName}'." + : $"Type '{actual.PublicName}' is not convertible to type '{expected.PublicName}'."; + + throw new ModelConversionException(state.Position, "Incompatible resource type found.", message, HttpStatusCode.Conflict); + } + } + + private IIdentifiable CreateResource(ResourceIdentity identity, ResourceIdentityRequirements requirements, ResourceType resourceType, + RequestAdapterState state) + { + AssertNoIdWithLid(identity, state); + + bool allowLid = requirements.EvaluateAllowLid?.Invoke(resourceType) ?? false; + + if (!allowLid) + { + AssertHasNoLid(identity, state); + } + + JsonElementConstraint? idConstraint = requirements.EvaluateIdConstraint?.Invoke(resourceType); + + if (idConstraint == JsonElementConstraint.Required) + { + AssertHasIdOrLid(identity, requirements, allowLid, state); + } + 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(resourceType.ClrType); + AssignStringId(identity, resource, state); + resource.LocalId = identity.Lid; + return resource; + } + + private static void AssertHasNoLid(ResourceIdentity identity, RequestAdapterState state) + { + if (identity.Lid != null) + { + using IDisposable _ = state.Position.PushElement("lid"); + + 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(ResourceIdentity identity, RequestAdapterState state) + { + 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(ResourceIdentity identity, ResourceIdentityRequirements requirements, bool allowLid, RequestAdapterState state) + { + string? message = null; + + if (requirements.IdValue != null && identity.Id == null) + { + message = "The 'id' element is required."; + } + else if (requirements.LidValue != null && identity.Lid == null) + { + message = "The 'lid' element is required."; + } + else if (identity.Id == null && identity.Lid == null) + { + message = allowLid ? "The 'id' or 'lid' element is required." : "The 'id' element is required."; + } + + if (message != null) + { + throw new ModelConversionException(state.Position, message, null); + } + } + + private static void AssertHasNoId(ResourceIdentity identity, RequestAdapterState state) + { + if (identity.Id != null) + { + using IDisposable _ = state.Position.PushElement("id"); + throw new ModelConversionException(state.Position, "The use of client-generated IDs is disabled.", null, HttpStatusCode.Forbidden); + } + } + + 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) + { + using IDisposable _ = state.Position.PushElement("id"); + + throw new ModelConversionException(state.Position, "Conflicting 'id' values found.", $"Expected '{expected}' instead of '{identity.Id}'.", + HttpStatusCode.Conflict); + } + } + + private static void AssertSameLidValue(ResourceIdentity identity, string? expected, RequestAdapterState state) + { + if (expected != null && identity.Lid != expected) + { + using IDisposable _ = state.Position.PushElement("lid"); + + throw new ModelConversionException(state.Position, "Conflicting 'lid' values found.", $"Expected '{expected}' instead of '{identity.Lid}'.", + HttpStatusCode.Conflict); + } + } + + private void AssignStringId(ResourceIdentity identity, IIdentifiable resource, RequestAdapterState state) + { + if (identity.Id != null) + { + try + { + resource.StringId = identity.Id; + } + catch (FormatException exception) + { + using IDisposable _ = state.Position.PushElement("id"); + throw new ModelConversionException(state.Position, "Incompatible 'id' value found.", exception.Message); + } + } + } + + 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.", + $"Relationship '{relationshipName}' does not exist on resource type '{resourceType.PublicName}'."); + } + } + + 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) + { + string message = state.Request.Kind == EndpointKind.AtomicOperations + ? "Only to-many relationships can be targeted through this operation." + : "Only to-many relationships can be targeted through this endpoint."; + + throw new ModelConversionException(state.Position, message, $"Relationship '{relationship.PublicName}' is not a to-many relationship.", + 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 new file mode 100644 index 0000000000..0168d2d5ea --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityRequirements.cs @@ -0,0 +1,69 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +/// Defines requirements to validate a instance against. +/// +[PublicAPI] +public sealed class ResourceIdentityRequirements +{ + /// + /// When not null, indicates that the "type" element must be compatible with the specified resource type. + /// + public ResourceType? ResourceType { get; init; } + + /// + /// When not null, provides a callback to indicate the presence or absence of the "id" element. + /// + 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. + /// + public string? IdValue { get; init; } + + /// + /// When not null, indicates what the value of the "lid" element must be. + /// + public string? LidValue { get; init; } + + /// + /// 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 new file mode 100644 index 0000000000..5f9b4dd05c --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceObjectAdapter.cs @@ -0,0 +1,158 @@ +using System.Diagnostics.CodeAnalysis; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Request.Adapters; + +/// +public sealed class ResourceObjectAdapter : ResourceIdentityAdapter, IResourceObjectAdapter +{ + private readonly IJsonApiOptions _options; + private readonly IRelationshipDataAdapter _relationshipDataAdapter; + + public ResourceObjectAdapter(IResourceGraph resourceGraph, IResourceFactory resourceFactory, IJsonApiOptions options, + IRelationshipDataAdapter relationshipDataAdapter) + : base(resourceGraph, resourceFactory) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(relationshipDataAdapter); + + _options = options; + _relationshipDataAdapter = relationshipDataAdapter; + } + + /// + public (IIdentifiable resource, ResourceType resourceType) Convert(ResourceObject resourceObject, ResourceIdentityRequirements requirements, + RequestAdapterState state) + { + ArgumentNullException.ThrowIfNull(resourceObject); + ArgumentNullException.ThrowIfNull(requirements); + ArgumentNullException.ThrowIfNull(state); + + (IIdentifiable resource, ResourceType resourceType) = ConvertResourceIdentity(resourceObject, requirements, state); + + ConvertAttributes(resourceObject.Attributes, resource, resourceType, state); + ConvertRelationships(resourceObject.Relationships, resource, resourceType, state); + + return (resource, resourceType); + } + + private void ConvertAttributes(IDictionary? resourceObjectAttributes, IIdentifiable resource, ResourceType resourceType, + RequestAdapterState state) + { + using IDisposable _ = state.Position.PushElement("attributes"); + + foreach ((string attributeName, object? attributeValue) in resourceObjectAttributes.EmptyIfNull()) + { + ConvertAttribute(resource, attributeName, attributeValue, resourceType, state); + } + } + + private void ConvertAttribute(IIdentifiable resource, string attributeName, object? attributeValue, ResourceType resourceType, RequestAdapterState state) + { + using IDisposable _ = state.Position.PushElement(attributeName); + AttrAttribute? attr = resourceType.FindAttributeByPublicName(attributeName); + + if (attr == null && _options.AllowUnknownFieldsInRequestBody) + { + return; + } + + AssertIsKnownAttribute(attr, attributeName, resourceType, state); + AssertNoInvalidAttribute(attributeValue, state); + AssertSetAttributeInCreateResourceNotBlocked(attr, resourceType, state); + AssertSetAttributeInUpdateResourceNotBlocked(attr, resourceType, state); + AssertNotReadOnly(attr, resourceType, state); + + attr.SetValue(resource, attributeValue); + state.WritableTargetedFields!.Attributes.Add(attr); + } + + private static void AssertIsKnownAttribute([NotNull] AttrAttribute? attr, string attributeName, ResourceType resourceType, RequestAdapterState state) + { + if (attr == null) + { + throw new ModelConversionException(state.Position, "Unknown attribute found.", + $"Attribute '{attributeName}' does not exist on resource type '{resourceType.PublicName}'."); + } + } + + private static void AssertNoInvalidAttribute(object? attributeValue, RequestAdapterState state) + { + if (attributeValue is JsonInvalidAttributeInfo info) + { + if (info == JsonInvalidAttributeInfo.Id) + { + throw new ModelConversionException(state.Position, "Resource ID is read-only.", null); + } + + string typeName = info.AttributeType.GetFriendlyTypeName(); + + throw new ModelConversionException(state.Position, "Incompatible attribute value found.", + $"Failed to convert attribute '{info.AttributeName}' with value '{info.JsonValue}' of type '{info.JsonType}' to type '{typeName}'."); + } + } + + private static void AssertSetAttributeInCreateResourceNotBlocked(AttrAttribute attr, ResourceType resourceType, RequestAdapterState state) + { + if (state.Request.WriteOperation == WriteOperationKind.CreateResource && !attr.Capabilities.HasFlag(AttrCapabilities.AllowCreate)) + { + throw new ModelConversionException(state.Position, "Attribute value cannot be assigned when creating resource.", + $"The attribute '{attr.PublicName}' on resource type '{resourceType.PublicName}' cannot be assigned to."); + } + } + + private static void AssertSetAttributeInUpdateResourceNotBlocked(AttrAttribute attr, ResourceType resourceType, RequestAdapterState state) + { + if (state.Request.WriteOperation == WriteOperationKind.UpdateResource && !attr.Capabilities.HasFlag(AttrCapabilities.AllowChange)) + { + throw new ModelConversionException(state.Position, "Attribute value cannot be assigned when updating resource.", + $"The attribute '{attr.PublicName}' on resource type '{resourceType.PublicName}' cannot be assigned to."); + } + } + + private static void AssertNotReadOnly(AttrAttribute attr, ResourceType resourceType, RequestAdapterState state) + { + if (attr.Property.SetMethod == null) + { + throw new ModelConversionException(state.Position, "Attribute is read-only.", + $"Attribute '{attr.PublicName}' on resource type '{resourceType.PublicName}' is read-only."); + } + } + + private void ConvertRelationships(IDictionary? resourceObjectRelationships, IIdentifiable resource, ResourceType resourceType, + RequestAdapterState state) + { + using IDisposable _ = state.Position.PushElement("relationships"); + + foreach ((string relationshipName, RelationshipObject? relationshipObject) in resourceObjectRelationships.EmptyIfNull()) + { + ConvertRelationship(relationshipName, relationshipObject, resource, resourceType, state); + } + } + + private void ConvertRelationship(string relationshipName, RelationshipObject? relationshipObject, IIdentifiable resource, ResourceType resourceType, + RequestAdapterState state) + { + using IDisposable _ = state.Position.PushElement(relationshipName); + AssertObjectIsNotNull(relationshipObject, state); + + RelationshipAttribute? relationship = resourceType.FindRelationshipByPublicName(relationshipName); + + if (relationship == null && _options.AllowUnknownFieldsInRequestBody) + { + return; + } + + AssertIsKnownRelationship(relationship, relationshipName, resourceType, state); + AssertRelationshipChangeNotBlocked(relationship, state); + + object? rightValue = _relationshipDataAdapter.Convert(relationshipObject.Data, relationship, true, state); + + relationship.SetValue(resource, rightValue); + state.WritableTargetedFields!.Relationships.Add(relationship); + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/IJsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/Request/IJsonApiReader.cs new file mode 100644 index 0000000000..be91910c75 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/IJsonApiReader.cs @@ -0,0 +1,17 @@ +using JetBrains.Annotations; +using Microsoft.AspNetCore.Http; + +namespace JsonApiDotNetCore.Serialization.Request; + +/// +/// Deserializes the incoming JSON:API request body and converts it to models, which are passed to controller actions by ASP.NET on `FromBody` +/// parameters. +/// +[PublicAPI] +public interface IJsonApiReader +{ + /// + /// Reads an object from the request body. + /// + Task ReadAsync(HttpRequest httpRequest); +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/JsonApiReader.cs b/src/JsonApiDotNetCore/Serialization/Request/JsonApiReader.cs new file mode 100644 index 0000000000..9c0139d949 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/JsonApiReader.cs @@ -0,0 +1,131 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Diagnostics; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Serialization.Request.Adapters; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Logging; +using SysNotNull = System.Diagnostics.CodeAnalysis.NotNullAttribute; + +namespace JsonApiDotNetCore.Serialization.Request; + +/// +public sealed partial class JsonApiReader : IJsonApiReader +{ + private readonly IJsonApiOptions _options; + private readonly IDocumentAdapter _documentAdapter; + private readonly ILogger _logger; + + public JsonApiReader(IJsonApiOptions options, IDocumentAdapter documentAdapter, ILogger logger) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(documentAdapter); + ArgumentNullException.ThrowIfNull(logger); + + _options = options; + _documentAdapter = documentAdapter; + _logger = logger; + } + + /// + public async Task ReadAsync(HttpRequest httpRequest) + { + ArgumentNullException.ThrowIfNull(httpRequest); + + string requestBody = await ReceiveRequestBodyAsync(httpRequest); + + if (_logger.IsEnabled(LogLevel.Trace)) + { + string requestMethod = httpRequest.Method.Replace(Environment.NewLine, ""); + string requestUrl = httpRequest.GetEncodedUrl(); + LogRequest(requestMethod, requestUrl, requestBody); + } + + return GetModel(requestBody); + } + + private static async Task ReceiveRequestBodyAsync(HttpRequest httpRequest) + { + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Receive request body"); + + using var reader = new HttpRequestStreamReader(httpRequest.Body, Encoding.UTF8); + return await reader.ReadToEndAsync(); + } + + private object? GetModel(string requestBody) + { + AssertHasRequestBody(requestBody); + + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Read request body"); + + Document document = DeserializeDocument(requestBody); + return ConvertDocumentToModel(document, requestBody); + } + + [AssertionMethod] + private static void AssertHasRequestBody(string requestBody) + { + if (string.IsNullOrEmpty(requestBody)) + { + throw new InvalidRequestBodyException(null, "Missing request body.", null, null, HttpStatusCode.BadRequest); + } + } + + private Document DeserializeDocument(string requestBody) + { + try + { + using IDisposable _ = + CodeTimingSessionManager.Current.Measure("JsonSerializer.Deserialize", MeasurementSettings.ExcludeJsonSerializationInPercentages); + + var document = JsonSerializer.Deserialize(requestBody, _options.SerializerReadOptions); + + AssertHasDocument(document, requestBody); + + return document; + } + catch (JsonException exception) + { + // JsonException.Path looks great for setting error.source.pointer, but unfortunately it is wrong in most cases. + // This is due to the use of custom converters, which are unable to interact with internal position tracking. + // 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) + { + if (document == null) + { + throw new InvalidRequestBodyException(_options.IncludeRequestBodyInErrors ? requestBody : null, "Expected an object, instead of 'null'.", null, + null); + } + } + + private object? ConvertDocumentToModel(Document document, string requestBody) + { + try + { + return _documentAdapter.Convert(document); + } + catch (ModelConversionException exception) + { + throw new InvalidRequestBodyException(_options.IncludeRequestBodyInErrors ? requestBody : null, exception.GenericMessage, exception.SpecificMessage, + 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 new file mode 100644 index 0000000000..4004e83de9 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/JsonInvalidAttributeInfo.cs @@ -0,0 +1,27 @@ +using System.Text.Json; + +namespace JsonApiDotNetCore.Serialization.Request; + +/// +/// A sentinel value that is temporarily stored in the attributes dictionary to postpone producing an error. +/// +internal sealed class JsonInvalidAttributeInfo +{ + public static readonly JsonInvalidAttributeInfo Id = new("id", typeof(string), "-", JsonValueKind.Undefined); + + public string AttributeName { get; } + public Type AttributeType { get; } + public string? JsonValue { get; } + public JsonValueKind JsonType { get; } + + public JsonInvalidAttributeInfo(string attributeName, Type attributeType, string? jsonValue, JsonValueKind jsonType) + { + ArgumentNullException.ThrowIfNull(attributeName); + ArgumentNullException.ThrowIfNull(attributeType); + + AttributeName = attributeName; + AttributeType = attributeType; + JsonValue = jsonValue; + JsonType = jsonType; + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Request/ModelConversionException.cs b/src/JsonApiDotNetCore/Serialization/Request/ModelConversionException.cs new file mode 100644 index 0000000000..02db72573d --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Request/ModelConversionException.cs @@ -0,0 +1,28 @@ +using System.Net; +using JetBrains.Annotations; +using JsonApiDotNetCore.Serialization.Request.Adapters; + +namespace JsonApiDotNetCore.Serialization.Request; + +/// +/// The error that is thrown when unable to convert a deserialized request body to an ASP.NET model. +/// +[PublicAPI] +public sealed class ModelConversionException : Exception +{ + public string? GenericMessage { get; } + public string? SpecificMessage { get; } + public HttpStatusCode? StatusCode { get; } + public string? SourcePointer { get; } + + public ModelConversionException(RequestAdapterPosition position, string? genericMessage, string? specificMessage, HttpStatusCode? statusCode = null) + : base(genericMessage) + { + ArgumentNullException.ThrowIfNull(position); + + GenericMessage = genericMessage; + SpecificMessage = specificMessage; + StatusCode = statusCode; + SourcePointer = position.ToSourcePointer(); + } +} 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/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs deleted file mode 100644 index d013736ce7..0000000000 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ /dev/null @@ -1,536 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using Humanizer; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Resources.Internal; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.AspNetCore.Http; -using Newtonsoft.Json.Linq; - -namespace JsonApiDotNetCore.Serialization -{ - /// - /// Server deserializer implementation of the . - /// - [PublicAPI] - public class RequestDeserializer : BaseDeserializer, IJsonApiDeserializer - { - private readonly ITargetedFields _targetedFields; - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly IJsonApiRequest _request; - private readonly IJsonApiOptions _options; - private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; - - public RequestDeserializer(IResourceContextProvider resourceContextProvider, IResourceFactory resourceFactory, ITargetedFields targetedFields, - IHttpContextAccessor httpContextAccessor, IJsonApiRequest request, IJsonApiOptions options) - : base(resourceContextProvider, resourceFactory) - { - ArgumentGuard.NotNull(targetedFields, nameof(targetedFields)); - ArgumentGuard.NotNull(httpContextAccessor, nameof(httpContextAccessor)); - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(options, nameof(options)); - - _targetedFields = targetedFields; - _httpContextAccessor = httpContextAccessor; - _request = request; - _options = options; - -#pragma warning disable 612 // Method is obsolete - _resourceDefinitionAccessor = resourceFactory.GetResourceDefinitionAccessor(); -#pragma warning restore 612 - } - - /// - public object Deserialize(string body) - { - ArgumentGuard.NotNullNorEmpty(body, nameof(body)); - - if (_request.Kind == EndpointKind.Relationship) - { - _targetedFields.Relationships.Add(_request.Relationship); - } - - if (_request.Kind == EndpointKind.AtomicOperations) - { - return DeserializeOperationsDocument(body); - } - - object instance = DeserializeBody(body); - - if (instance is IIdentifiable resource && _request.Kind != EndpointKind.Relationship) - { - _resourceDefinitionAccessor.OnDeserialize(resource); - } - - AssertResourceIdIsNotTargeted(_targetedFields); - - return instance; - } - - private object DeserializeOperationsDocument(string body) - { - JToken bodyToken = LoadJToken(body); - var document = bodyToken.ToObject(); - - if ((document?.Operations).IsNullOrEmpty()) - { - throw new JsonApiSerializationException("No operations found.", null); - } - - if (document.Operations.Count > _options.MaximumOperationsPerRequest) - { - throw new JsonApiSerializationException("Request exceeds the maximum number of operations.", - $"The number of operations in this request ({document.Operations.Count}) is higher than {_options.MaximumOperationsPerRequest}."); - } - - var operations = new List(); - AtomicOperationIndex = 0; - - foreach (AtomicOperationObject operation in document.Operations) - { - OperationContainer container = DeserializeOperation(operation); - operations.Add(container); - - AtomicOperationIndex++; - } - - return operations; - } - - private OperationContainer DeserializeOperation(AtomicOperationObject operation) - { - _targetedFields.Attributes.Clear(); - _targetedFields.Relationships.Clear(); - - AssertHasNoHref(operation); - - OperationKind kind = GetOperationKind(operation); - - switch (kind) - { - case OperationKind.CreateResource: - case OperationKind.UpdateResource: - { - return ParseForCreateOrUpdateResourceOperation(operation, kind); - } - case OperationKind.DeleteResource: - { - return ParseForDeleteResourceOperation(operation, kind); - } - } - - bool requireToManyRelationship = kind == OperationKind.AddToRelationship || kind == OperationKind.RemoveFromRelationship; - - return ParseForRelationshipOperation(operation, kind, requireToManyRelationship); - } - - [AssertionMethod] - private void AssertHasNoHref(AtomicOperationObject operation) - { - if (operation.Href != null) - { - throw new JsonApiSerializationException("Usage of the 'href' element is not supported.", null, atomicOperationIndex: AtomicOperationIndex); - } - } - - private OperationKind GetOperationKind(AtomicOperationObject operation) - { - switch (operation.Code) - { - case AtomicOperationCode.Add: - { - if (operation.Ref != null && operation.Ref.Relationship == null) - { - throw new JsonApiSerializationException("The 'ref.relationship' element is required.", null, - atomicOperationIndex: AtomicOperationIndex); - } - - return operation.Ref == null ? OperationKind.CreateResource : OperationKind.AddToRelationship; - } - case AtomicOperationCode.Update: - { - return operation.Ref?.Relationship != null ? OperationKind.SetRelationship : OperationKind.UpdateResource; - } - case AtomicOperationCode.Remove: - { - if (operation.Ref == null) - { - throw new JsonApiSerializationException("The 'ref' element is required.", null, atomicOperationIndex: AtomicOperationIndex); - } - - return operation.Ref.Relationship != null ? OperationKind.RemoveFromRelationship : OperationKind.DeleteResource; - } - } - - throw new NotSupportedException($"Unknown operation code '{operation.Code}'."); - } - - private OperationContainer ParseForCreateOrUpdateResourceOperation(AtomicOperationObject operation, OperationKind kind) - { - ResourceObject resourceObject = GetRequiredSingleDataForResourceOperation(operation); - - AssertElementHasType(resourceObject, "data"); - AssertElementHasIdOrLid(resourceObject, "data", kind != OperationKind.CreateResource); - - ResourceContext primaryResourceContext = GetExistingResourceContext(resourceObject.Type); - - AssertCompatibleId(resourceObject, primaryResourceContext.IdentityType); - - if (operation.Ref != null) - { - // For resource update, 'ref' is optional. But when specified, it must match with 'data'. - - AssertElementHasType(operation.Ref, "ref"); - AssertElementHasIdOrLid(operation.Ref, "ref", true); - - ResourceContext resourceContextInRef = GetExistingResourceContext(operation.Ref.Type); - - if (resourceContextInRef != primaryResourceContext) - { - throw new JsonApiSerializationException("Resource type mismatch between 'ref.type' and 'data.type' element.", - $"Expected resource of type '{resourceContextInRef.PublicName}' in 'data.type', instead of '{primaryResourceContext.PublicName}'.", - atomicOperationIndex: AtomicOperationIndex); - } - - AssertSameIdentityInRefData(operation, resourceObject); - } - - var request = new JsonApiRequest - { - Kind = EndpointKind.AtomicOperations, -#pragma warning disable CS0618 // Type or member is obsolete - BasePath = _request.BasePath, -#pragma warning restore CS0618 // Type or member is obsolete - PrimaryResource = primaryResourceContext, - OperationKind = kind - }; - - _request.CopyFrom(request); - - IIdentifiable primaryResource = ParseResourceObject(operation.SingleData); - - _resourceDefinitionAccessor.OnDeserialize(primaryResource); - - request.PrimaryId = primaryResource.StringId; - _request.CopyFrom(request); - - var targetedFields = new TargetedFields - { - Attributes = _targetedFields.Attributes.ToHashSet(), - Relationships = _targetedFields.Relationships.ToHashSet() - }; - - AssertResourceIdIsNotTargeted(targetedFields); - - return new OperationContainer(kind, primaryResource, targetedFields, request); - } - - private ResourceObject GetRequiredSingleDataForResourceOperation(AtomicOperationObject operation) - { - if (operation.Data == null) - { - throw new JsonApiSerializationException("The 'data' element is required.", null, atomicOperationIndex: AtomicOperationIndex); - } - - if (operation.SingleData == null) - { - throw new JsonApiSerializationException("Expected single data element for create/update resource operation.", null, - atomicOperationIndex: AtomicOperationIndex); - } - - return operation.SingleData; - } - - [AssertionMethod] - private void AssertElementHasType(ResourceIdentifierObject resourceIdentifierObject, string elementPath) - { - if (resourceIdentifierObject.Type == null) - { - throw new JsonApiSerializationException($"The '{elementPath}.type' element is required.", null, atomicOperationIndex: AtomicOperationIndex); - } - } - - private void AssertElementHasIdOrLid(ResourceIdentifierObject resourceIdentifierObject, string elementPath, bool isRequired) - { - bool hasNone = resourceIdentifierObject.Id == null && resourceIdentifierObject.Lid == null; - bool hasBoth = resourceIdentifierObject.Id != null && resourceIdentifierObject.Lid != null; - - if (isRequired ? hasNone || hasBoth : hasBoth) - { - throw new JsonApiSerializationException($"The '{elementPath}.id' or '{elementPath}.lid' element is required.", null, - atomicOperationIndex: AtomicOperationIndex); - } - } - - private void AssertCompatibleId(ResourceIdentifierObject resourceIdentifierObject, Type idType) - { - if (resourceIdentifierObject.Id != null) - { - try - { - RuntimeTypeConverter.ConvertType(resourceIdentifierObject.Id, idType); - } - catch (FormatException exception) - { - throw new JsonApiSerializationException(null, exception.Message, null, AtomicOperationIndex); - } - } - } - - private void AssertSameIdentityInRefData(AtomicOperationObject operation, ResourceIdentifierObject resourceIdentifierObject) - { - if (operation.Ref.Id != null && resourceIdentifierObject.Id != null && resourceIdentifierObject.Id != operation.Ref.Id) - { - throw new JsonApiSerializationException("Resource ID mismatch between 'ref.id' and 'data.id' element.", - $"Expected resource with ID '{operation.Ref.Id}' in 'data.id', instead of '{resourceIdentifierObject.Id}'.", - atomicOperationIndex: AtomicOperationIndex); - } - - if (operation.Ref.Lid != null && resourceIdentifierObject.Lid != null && resourceIdentifierObject.Lid != operation.Ref.Lid) - { - throw new JsonApiSerializationException("Resource local ID mismatch between 'ref.lid' and 'data.lid' element.", - $"Expected resource with local ID '{operation.Ref.Lid}' in 'data.lid', instead of '{resourceIdentifierObject.Lid}'.", - atomicOperationIndex: AtomicOperationIndex); - } - - if (operation.Ref.Id != null && resourceIdentifierObject.Lid != null) - { - throw new JsonApiSerializationException("Resource identity mismatch between 'ref.id' and 'data.lid' element.", - $"Expected resource with ID '{operation.Ref.Id}' in 'data.id', instead of '{resourceIdentifierObject.Lid}' in 'data.lid'.", - atomicOperationIndex: AtomicOperationIndex); - } - - if (operation.Ref.Lid != null && resourceIdentifierObject.Id != null) - { - throw new JsonApiSerializationException("Resource identity mismatch between 'ref.lid' and 'data.id' element.", - $"Expected resource with local ID '{operation.Ref.Lid}' in 'data.lid', instead of '{resourceIdentifierObject.Id}' in 'data.id'.", - atomicOperationIndex: AtomicOperationIndex); - } - } - - private OperationContainer ParseForDeleteResourceOperation(AtomicOperationObject operation, OperationKind kind) - { - AssertElementHasType(operation.Ref, "ref"); - AssertElementHasIdOrLid(operation.Ref, "ref", true); - - ResourceContext primaryResourceContext = GetExistingResourceContext(operation.Ref.Type); - - AssertCompatibleId(operation.Ref, primaryResourceContext.IdentityType); - - IIdentifiable primaryResource = ResourceFactory.CreateInstance(primaryResourceContext.ResourceType); - primaryResource.StringId = operation.Ref.Id; - primaryResource.LocalId = operation.Ref.Lid; - - var request = new JsonApiRequest - { - Kind = EndpointKind.AtomicOperations, -#pragma warning disable CS0618 // Type or member is obsolete - BasePath = _request.BasePath, -#pragma warning restore CS0618 // Type or member is obsolete - PrimaryId = primaryResource.StringId, - PrimaryResource = primaryResourceContext, - OperationKind = kind - }; - - return new OperationContainer(kind, primaryResource, new TargetedFields(), request); - } - - private OperationContainer ParseForRelationshipOperation(AtomicOperationObject operation, OperationKind kind, bool requireToMany) - { - AssertElementHasType(operation.Ref, "ref"); - AssertElementHasIdOrLid(operation.Ref, "ref", true); - - ResourceContext primaryResourceContext = GetExistingResourceContext(operation.Ref.Type); - - AssertCompatibleId(operation.Ref, primaryResourceContext.IdentityType); - - IIdentifiable primaryResource = ResourceFactory.CreateInstance(primaryResourceContext.ResourceType); - primaryResource.StringId = operation.Ref.Id; - primaryResource.LocalId = operation.Ref.Lid; - - RelationshipAttribute relationship = GetExistingRelationship(operation.Ref, primaryResourceContext); - - if (requireToMany && relationship is HasOneAttribute) - { - throw new JsonApiSerializationException($"Only to-many relationships can be targeted in '{operation.Code.ToString().Camelize()}' operations.", - $"Relationship '{operation.Ref.Relationship}' must be a to-many relationship.", atomicOperationIndex: AtomicOperationIndex); - } - - ResourceContext secondaryResourceContext = ResourceContextProvider.GetResourceContext(relationship.RightType); - - var request = new JsonApiRequest - { - Kind = EndpointKind.AtomicOperations, -#pragma warning disable CS0618 // Type or member is obsolete - BasePath = _request.BasePath, -#pragma warning restore CS0618 // Type or member is obsolete - PrimaryId = primaryResource.StringId, - PrimaryResource = primaryResourceContext, - SecondaryResource = secondaryResourceContext, - Relationship = relationship, - IsCollection = relationship is HasManyAttribute, - OperationKind = kind - }; - - _request.CopyFrom(request); - - _targetedFields.Relationships.Add(relationship); - - ParseDataForRelationship(relationship, secondaryResourceContext, operation, primaryResource); - - var targetedFields = new TargetedFields - { - Attributes = _targetedFields.Attributes.ToHashSet(), - Relationships = _targetedFields.Relationships.ToHashSet() - }; - - return new OperationContainer(kind, primaryResource, targetedFields, request); - } - - private RelationshipAttribute GetExistingRelationship(AtomicReference reference, ResourceContext resourceContext) - { - RelationshipAttribute relationship = resourceContext.Relationships.FirstOrDefault(attribute => attribute.PublicName == reference.Relationship); - - if (relationship == null) - { - throw new JsonApiSerializationException("The referenced relationship does not exist.", - $"Resource of type '{reference.Type}' does not contain a relationship named '{reference.Relationship}'.", - atomicOperationIndex: AtomicOperationIndex); - } - - return relationship; - } - - private void ParseDataForRelationship(RelationshipAttribute relationship, ResourceContext secondaryResourceContext, AtomicOperationObject operation, - IIdentifiable primaryResource) - { - if (relationship is HasOneAttribute) - { - if (operation.ManyData != null) - { - throw new JsonApiSerializationException("Expected single data element for to-one relationship.", - $"Expected single data element for '{relationship.PublicName}' relationship.", atomicOperationIndex: AtomicOperationIndex); - } - - if (operation.SingleData != null) - { - ValidateSingleDataForRelationship(operation.SingleData, secondaryResourceContext, "data"); - - IIdentifiable secondaryResource = ParseResourceObject(operation.SingleData); - relationship.SetValue(primaryResource, secondaryResource); - } - } - else if (relationship is HasManyAttribute) - { - if (operation.ManyData == null) - { - throw new JsonApiSerializationException("Expected data[] element for to-many relationship.", - $"Expected data[] element for '{relationship.PublicName}' relationship.", atomicOperationIndex: AtomicOperationIndex); - } - - var secondaryResources = new List(); - - foreach (ResourceObject resourceObject in operation.ManyData) - { - ValidateSingleDataForRelationship(resourceObject, secondaryResourceContext, "data[]"); - - IIdentifiable secondaryResource = ParseResourceObject(resourceObject); - secondaryResources.Add(secondaryResource); - } - - IEnumerable rightResources = CollectionConverter.CopyToTypedCollection(secondaryResources, relationship.Property.PropertyType); - relationship.SetValue(primaryResource, rightResources); - } - } - - private void ValidateSingleDataForRelationship(ResourceObject dataResourceObject, ResourceContext resourceContext, string elementPath) - { - AssertElementHasType(dataResourceObject, elementPath); - AssertElementHasIdOrLid(dataResourceObject, elementPath, true); - - ResourceContext resourceContextInData = GetExistingResourceContext(dataResourceObject.Type); - - AssertCompatibleType(resourceContextInData, resourceContext, elementPath); - AssertCompatibleId(dataResourceObject, resourceContextInData.IdentityType); - } - - private void AssertCompatibleType(ResourceContext resourceContextInData, ResourceContext resourceContextInRef, string elementPath) - { - if (!resourceContextInData.ResourceType.IsAssignableFrom(resourceContextInRef.ResourceType)) - { - throw new JsonApiSerializationException($"Resource type mismatch between 'ref.relationship' and '{elementPath}.type' element.", - $"Expected resource of type '{resourceContextInRef.PublicName}' in '{elementPath}.type', instead of '{resourceContextInData.PublicName}'.", - atomicOperationIndex: AtomicOperationIndex); - } - } - - private void AssertResourceIdIsNotTargeted(ITargetedFields targetedFields) - { - if (!_request.IsReadOnly && targetedFields.Attributes.Any(attribute => attribute.Property.Name == nameof(Identifiable.Id))) - { - throw new JsonApiSerializationException("Resource ID is read-only.", null, atomicOperationIndex: AtomicOperationIndex); - } - } - - /// - /// Additional processing required for server deserialization. Flags a processed attribute or relationship as updated using - /// . - /// - /// - /// The resource that was constructed from the document's body. - /// - /// - /// The metadata for the exposed field. - /// - /// - /// Relationship data for . Is null when is not a . - /// - protected override void AfterProcessField(IIdentifiable resource, ResourceFieldAttribute field, RelationshipEntry data = null) - { - bool isCreatingResource = IsCreatingResource(); - bool isUpdatingResource = IsUpdatingResource(); - - if (field is AttrAttribute attr) - { - if (isCreatingResource && !attr.Capabilities.HasFlag(AttrCapabilities.AllowCreate)) - { - throw new JsonApiSerializationException("Setting the initial value of the requested attribute is not allowed.", - $"Setting the initial value of '{attr.PublicName}' is not allowed.", atomicOperationIndex: AtomicOperationIndex); - } - - if (isUpdatingResource && !attr.Capabilities.HasFlag(AttrCapabilities.AllowChange)) - { - throw new JsonApiSerializationException("Changing the value of the requested attribute is not allowed.", - $"Changing the value of '{attr.PublicName}' is not allowed.", atomicOperationIndex: AtomicOperationIndex); - } - - _targetedFields.Attributes.Add(attr); - } - else if (field is RelationshipAttribute relationship) - { - _targetedFields.Relationships.Add(relationship); - } - } - - private bool IsCreatingResource() - { - return _request.Kind == EndpointKind.AtomicOperations - ? _request.OperationKind == OperationKind.CreateResource - : _request.Kind == EndpointKind.Primary && _httpContextAccessor.HttpContext.Request.Method == HttpMethod.Post.Method; - } - - private bool IsUpdatingResource() - { - return _request.Kind == EndpointKind.AtomicOperations - ? _request.OperationKind == OperationKind.UpdateResource - : _request.Kind == EndpointKind.Primary && _httpContextAccessor.HttpContext.Request.Method == HttpMethod.Patch.Method; - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/Response/ETagGenerator.cs b/src/JsonApiDotNetCore/Serialization/Response/ETagGenerator.cs new file mode 100644 index 0000000000..e88d9c17d4 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Response/ETagGenerator.cs @@ -0,0 +1,31 @@ +using Microsoft.Net.Http.Headers; + +namespace JsonApiDotNetCore.Serialization.Response; + +/// +internal sealed class ETagGenerator : IETagGenerator +{ + private readonly IFingerprintGenerator _fingerprintGenerator; + + public ETagGenerator(IFingerprintGenerator fingerprintGenerator) + { + ArgumentNullException.ThrowIfNull(fingerprintGenerator); + + _fingerprintGenerator = fingerprintGenerator; + } + + /// + public EntityTagHeaderValue Generate(string requestUrl, string 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 new file mode 100644 index 0000000000..0f4032edd1 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Response/EmptyResponseMeta.cs @@ -0,0 +1,11 @@ +namespace JsonApiDotNetCore.Serialization.Response; + +/// +public sealed class EmptyResponseMeta : IResponseMeta +{ + /// + public IDictionary? GetMeta() + { + return null; + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Response/FingerprintGenerator.cs b/src/JsonApiDotNetCore/Serialization/Response/FingerprintGenerator.cs new file mode 100644 index 0000000000..61f3349df3 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Response/FingerprintGenerator.cs @@ -0,0 +1,51 @@ +using System.Security.Cryptography; +using System.Text; + +namespace JsonApiDotNetCore.Serialization.Response; + +/// +internal sealed class FingerprintGenerator : IFingerprintGenerator +{ + 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) + { + string hex = index.ToString("X2"); + return hex[0] + ((uint)hex[1] << 16); + } + + /// + public string Generate(IEnumerable elements) + { + ArgumentNullException.ThrowIfNull(elements); + + using var hasher = IncrementalHash.CreateHash(HashAlgorithmName.MD5); + + foreach (string element in elements) + { + byte[] buffer = Encoding.UTF8.GetBytes(element); + hasher.AppendData(buffer); + hasher.AppendData(Separator); + } + + byte[] hash = hasher.GetHashAndReset(); + return ByteArrayToHex(hash); + } + + private static string ByteArrayToHex(byte[] bytes) + { + // https://stackoverflow.com/questions/311165/how-do-you-convert-a-byte-array-to-a-hexadecimal-string-and-vice-versa + + char[] buffer = new char[bytes.Length * 2]; + + for (int index = 0; index < bytes.Length; index++) + { + uint value = LookupTable[bytes[index]]; + buffer[2 * index] = (char)value; + buffer[2 * index + 1] = (char)(value >> 16); + } + + return new string(buffer); + } +} 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/IETagGenerator.cs b/src/JsonApiDotNetCore/Serialization/Response/IETagGenerator.cs new file mode 100644 index 0000000000..1adcbb8515 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Response/IETagGenerator.cs @@ -0,0 +1,23 @@ +using Microsoft.Net.Http.Headers; + +namespace JsonApiDotNetCore.Serialization.Response; + +/// +/// Provides generation of an ETag HTTP response header. +/// +public interface IETagGenerator +{ + /// + /// Generates an ETag HTTP response header value for the response to an incoming request. + /// + /// + /// The incoming request URL, including query string. + /// + /// + /// The produced response body. + /// + /// + /// The ETag, or null to disable saving bandwidth. + /// + public EntityTagHeaderValue Generate(string requestUrl, string responseBody); +} diff --git a/src/JsonApiDotNetCore/Serialization/Response/IFingerprintGenerator.cs b/src/JsonApiDotNetCore/Serialization/Response/IFingerprintGenerator.cs new file mode 100644 index 0000000000..d1189c4076 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Response/IFingerprintGenerator.cs @@ -0,0 +1,15 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Serialization.Response; + +/// +/// Provides a method to generate a fingerprint for a collection of string values. +/// +[PublicAPI] +public interface IFingerprintGenerator +{ + /// + /// Generates a fingerprint for the specified elements. + /// + public string Generate(IEnumerable elements); +} diff --git a/src/JsonApiDotNetCore/Serialization/Response/IJsonApiWriter.cs b/src/JsonApiDotNetCore/Serialization/Response/IJsonApiWriter.cs new file mode 100644 index 0000000000..62087d2adb --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Response/IJsonApiWriter.cs @@ -0,0 +1,16 @@ +using JetBrains.Annotations; +using Microsoft.AspNetCore.Http; + +namespace JsonApiDotNetCore.Serialization.Response; + +/// +/// Serializes ASP.NET models into the outgoing JSON:API response body. +/// +[PublicAPI] +public interface IJsonApiWriter +{ + /// + /// Writes an object to the response body. + /// + Task WriteAsync(object? model, HttpContext httpContext); +} diff --git a/src/JsonApiDotNetCore/Serialization/Response/ILinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/ILinkBuilder.cs new file mode 100644 index 0000000000..e8c9870b85 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Response/ILinkBuilder.cs @@ -0,0 +1,27 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Response; + +/// +/// Builds resource object links and relationship object links. +/// +public interface ILinkBuilder +{ + /// + /// Builds the links object that is included in the top-level of the document. + /// + TopLevelLinks? GetTopLevelLinks(); + + /// + /// Builds the links object for a returned resource (primary or included). + /// + ResourceLinks? GetResourceLinks(ResourceType resourceType, IIdentifiable resource); + + /// + /// Builds the links object for a relationship inside a returned resource. + /// + RelationshipLinks? GetRelationshipLinks(RelationshipAttribute relationship, IIdentifiable leftResource); +} diff --git a/src/JsonApiDotNetCore/Serialization/Response/IMetaBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/IMetaBuilder.cs new file mode 100644 index 0000000000..1c3916c404 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Response/IMetaBuilder.cs @@ -0,0 +1,23 @@ +using JetBrains.Annotations; + +namespace JsonApiDotNetCore.Serialization.Response; + +/// +/// Builds the top-level meta object. +/// +[PublicAPI] +public interface IMetaBuilder +{ + /// + /// Merges the specified dictionary with existing key/value pairs. In the event of a key collision, the value from the specified dictionary will + /// overwrite the existing one. + /// + void Add(IDictionary values); + + /// + /// Builds the top-level meta data object. + /// +#pragma warning disable AV1130 // Return type in method signature should be an interface to an unchangeable collection + IDictionary? Build(); +#pragma warning restore AV1130 // Return type in method signature should be an interface to an unchangeable collection +} diff --git a/src/JsonApiDotNetCore/Serialization/Response/IResponseMeta.cs b/src/JsonApiDotNetCore/Serialization/Response/IResponseMeta.cs new file mode 100644 index 0000000000..2e17ec9bdb --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Response/IResponseMeta.cs @@ -0,0 +1,18 @@ +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Response; + +/// +/// Provides a method to obtain global JSON:API meta, which is added at top-level to a response . Use +/// to specify nested metadata per individual resource. +/// +public interface IResponseMeta +{ + /// + /// Gets the global top-level JSON:API meta information to add to the response. + /// +#pragma warning disable AV1130 // Return type in method signature should be an interface to an unchangeable collection + IDictionary? GetMeta(); +#pragma warning restore AV1130 // Return type in method signature should be an interface to an unchangeable collection +} diff --git a/src/JsonApiDotNetCore/Serialization/Response/IResponseModelAdapter.cs b/src/JsonApiDotNetCore/Serialization/Response/IResponseModelAdapter.cs new file mode 100644 index 0000000000..49b0f65c95 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Response/IResponseModelAdapter.cs @@ -0,0 +1,46 @@ +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Response; + +/// +/// Converts the produced model from an ASP.NET controller action into a , ready to be serialized as the response body. +/// +public interface IResponseModelAdapter +{ + /// + /// Validates and converts the specified . Supported model types: + /// + /// + /// + /// ]]> + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// ]]> + /// + /// + /// + /// + /// ]]> + /// + /// + /// + /// + /// + /// + /// + /// + /// + Document Convert(object? model); +} diff --git a/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs b/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs new file mode 100644 index 0000000000..67bb61213b --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Response/JsonApiWriter.cs @@ -0,0 +1,188 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Diagnostics; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; +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 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 ILogger _logger; + + public JsonApiWriter(IJsonApiRequest request, IJsonApiOptions options, IResponseModelAdapter responseModelAdapter, IExceptionHandler exceptionHandler, + IETagGenerator eTagGenerator, ILogger logger) + { + 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; + _logger = logger; + } + + /// + public async Task WriteAsync(object? model, HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext); + + if (model == null && !CanWriteBody((HttpStatusCode)httpContext.Response.StatusCode)) + { + // Prevent exception from Kestrel server, caused by writing data:null json response. + return; + } + + string? responseBody = GetResponseBody(model, httpContext); + + if (httpContext.Request.Method == HttpMethod.Head.Method) + { + httpContext.Response.GetTypedHeaders().ContentLength = responseBody == null ? 0 : Encoding.UTF8.GetByteCount(responseBody); + return; + } + + 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); + } + + var responseMediaType = new JsonApiMediaType(_request.Extensions); + await SendResponseBodyAsync(httpContext.Response, responseBody, responseMediaType.ToString()); + } + + private static bool CanWriteBody(HttpStatusCode statusCode) + { + return statusCode is not HttpStatusCode.NoContent and not HttpStatusCode.ResetContent and not HttpStatusCode.NotModified; + } + + private string? GetResponseBody(object? model, HttpContext httpContext) + { + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Write response body"); + + try + { + if (model is ProblemDetails problemDetails) + { + throw new UnsuccessfulActionResultException(problemDetails); + } + + if (model == null && !IsSuccessStatusCode((HttpStatusCode)httpContext.Response.StatusCode)) + { + throw new UnsuccessfulActionResultException((HttpStatusCode)httpContext.Response.StatusCode); + } + + string responseBody = RenderModel(model); + + if (SetETagResponseHeader(httpContext.Request, httpContext.Response, responseBody)) + { + httpContext.Response.StatusCode = (int)HttpStatusCode.NotModified; + return null; + } + + return responseBody; + } +#pragma warning disable AV1210 // Catch a specific exception instead of Exception, SystemException or ApplicationException + catch (Exception exception) +#pragma warning restore AV1210 // Catch a specific exception instead of Exception, SystemException or ApplicationException + { + IReadOnlyList errors = _exceptionHandler.HandleException(exception); + httpContext.Response.StatusCode = (int)ErrorObject.GetResponseStatusCode(errors); + + return RenderModel(errors); + } + } + + private static bool IsSuccessStatusCode(HttpStatusCode statusCode) + { + return new HttpResponseMessage(statusCode).IsSuccessStatusCode; + } + + private string RenderModel(object? model) + { + Document document = _responseModelAdapter.Convert(model); + return SerializeDocument(document); + } + + private string SerializeDocument(Document document) + { + using IDisposable _ = CodeTimingSessionManager.Current.Measure("JsonSerializer.Serialize", MeasurementSettings.ExcludeJsonSerializationInPercentages); + + return JsonSerializer.Serialize(document, _options.SerializerWriteOptions); + } + + private bool SetETagResponseHeader(HttpRequest request, HttpResponse response, string responseContent) + { + bool isReadOnly = request.Method == HttpMethod.Get.Method || request.Method == HttpMethod.Head.Method; + + if (isReadOnly && response.StatusCode == (int)HttpStatusCode.OK) + { + string url = request.GetEncodedUrl(); + EntityTagHeaderValue responseETag = _eTagGenerator.Generate(url, responseContent); + + response.Headers.Append(HeaderNames.ETag, responseETag.ToString()); + + return RequestContainsMatchingETag(request.Headers, responseETag); + } + + return false; + } + + private static bool RequestContainsMatchingETag(IHeaderDictionary requestHeaders, EntityTagHeaderValue responseETag) + { + if (requestHeaders.TryGetValue(HeaderNames.IfNoneMatch, out StringValues headerValues) && + EntityTagHeaderValue.TryParseList(headerValues, out IList? requestETags)) + { + foreach (EntityTagHeaderValue requestETag in requestETags) + { + if (responseETag.Equals(requestETag)) + { + return true; + } + } + } + + return false; + } + + private async Task SendResponseBodyAsync(HttpResponse httpResponse, string? responseBody, string contentType) + { + if (!string.IsNullOrEmpty(responseBody)) + { + httpResponse.ContentType = contentType; + + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Send response body"); + + await using TextWriter writer = new HttpResponseStreamWriter(httpResponse.Body, Encoding.UTF8); + await writer.WriteAsync(responseBody); + 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 new file mode 100644 index 0000000000..b7f200dd48 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs @@ -0,0 +1,369 @@ +using System.Collections.Immutable; +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.Queries.Parsing; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Routing; + +namespace JsonApiDotNetCore.Serialization.Response; + +/// +[PublicAPI] +public class LinkBuilder : ILinkBuilder +{ + private const string PageSizeParameterName = "page[size]"; + private const string PageNumberParameterName = "page[number]"; + + private static readonly string GetPrimaryControllerActionName = NoAsyncSuffix(nameof(BaseJsonApiController, int>.GetAsync)); + private static readonly string GetSecondaryControllerActionName = NoAsyncSuffix(nameof(BaseJsonApiController, int>.GetSecondaryAsync)); + + 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 + { + get + { + if (_httpContextAccessor.HttpContext == null) + { + throw new InvalidOperationException("An active HTTP request is required."); + } + + return _httpContextAccessor.HttpContext; + } + } + + public LinkBuilder(IJsonApiOptions options, IJsonApiRequest request, IPaginationContext paginationContext, IHttpContextAccessor httpContextAccessor, + LinkGenerator linkGenerator, IControllerResourceMapping controllerResourceMapping, IPaginationParser paginationParser, + IDocumentDescriptionLinkProvider documentDescriptionLinkProvider) + { + 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; + _paginationContext = paginationContext; + _httpContextAccessor = httpContextAccessor; + _linkGenerator = linkGenerator; + _controllerResourceMapping = controllerResourceMapping; + _paginationParser = paginationParser; + _documentDescriptionLinkProvider = documentDescriptionLinkProvider; + } + + private static string NoAsyncSuffix(string actionName) + { + return actionName.EndsWith("Async", StringComparison.Ordinal) ? actionName[..^"Async".Length] : actionName; + } + + /// + public TopLevelLinks? GetTopLevelLinks() + { + var links = new TopLevelLinks(); + ResourceType? resourceType = _request.SecondaryResourceType ?? _request.PrimaryResourceType; + + if (ShouldIncludeTopLevelLink(LinkTypes.Self, resourceType)) + { + links.Self = GetLinkForTopLevelSelf(); + } + + 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.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; + } + + /// + /// Checks if the top-level should be added by first checking configuration on the , and if not + /// configured, by checking with the global configuration in . + /// + private bool ShouldIncludeTopLevelLink(LinkTypes linkType, ResourceType? resourceType) + { + if (resourceType != null && resourceType.TopLevelLinks != LinkTypes.NotConfigured) + { + return resourceType.TopLevelLinks.HasFlag(linkType); + } + + return _options.TopLevelLinks.HasFlag(linkType); + } + + private string GetLinkForTopLevelSelf() + { + // Note: in tests, this does not properly escape special characters due to WebApplicationFactory short-circuiting. + return _options.UseRelativeLinks ? HttpContext.Request.GetEncodedPathAndQuery() : HttpContext.Request.GetEncodedUrl(); + } + + private void SetPaginationInTopLevelLinks(ResourceType resourceType, TopLevelLinks links) + { + string? pageSizeValue = CalculatePageSizeValue(_paginationContext.PageSize, resourceType); + + links.First = GetLinkForPagination(1, pageSizeValue); + + if (_paginationContext.TotalPageCount > 0) + { + links.Last = GetLinkForPagination(_paginationContext.TotalPageCount.Value, pageSizeValue); + } + + if (_paginationContext.PageNumber.OneBasedValue > 1) + { + links.Prev = GetLinkForPagination(_paginationContext.PageNumber.OneBasedValue - 1, pageSizeValue); + } + + bool hasNextPage = _paginationContext.PageNumber.OneBasedValue < _paginationContext.TotalPageCount; + bool possiblyHasNextPage = _paginationContext.TotalPageCount == null && _paginationContext.IsPageFull; + + if (hasNextPage || possiblyHasNextPage) + { + links.Next = GetLinkForPagination(_paginationContext.PageNumber.OneBasedValue + 1, pageSizeValue); + } + } + + private string? CalculatePageSizeValue(PageSize? topPageSize, ResourceType resourceType) + { + 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) + { + IImmutableList elements = ParsePageSizeExpression(pageSizeParameterValue, resourceType); + int elementInTopScopeIndex = elements.FindIndex(expression => expression.Scope == null); + + if (topPageSize != null) + { + var topPageSizeElement = new PaginationElementQueryStringValueExpression(null, topPageSize.Value, -1); + + elements = elementInTopScopeIndex != -1 ? elements.SetItem(elementInTopScopeIndex, topPageSizeElement) : elements.Insert(0, topPageSizeElement); + } + else + { + if (elementInTopScopeIndex != -1) + { + elements = elements.RemoveAt(elementInTopScopeIndex); + } + } + + string parameterValue = string.Join(',', + elements.Select(expression => expression.Scope == null ? expression.Value.ToString() : $"{expression.Scope}:{expression.Value}")); + + return parameterValue.Length == 0 ? null : parameterValue; + } + + private IImmutableList ParsePageSizeExpression(string? pageSizeParameterValue, ResourceType resourceType) + { + if (pageSizeParameterValue == null) + { + return ImmutableArray.Empty; + } + + PaginationQueryStringValueExpression pagination = _paginationParser.Parse(pageSizeParameterValue, resourceType); + + return pagination.Elements; + } + + private string GetLinkForPagination(int pageOffset, string? pageSizeValue) + { + string queryStringValue = GetQueryStringInPaginationLink(pageOffset, pageSizeValue); + + var builder = new UriBuilder(HttpContext.Request.GetEncodedUrl()) + { + Query = queryStringValue + }; + + UriComponents components = _options.UseRelativeLinks ? UriComponents.PathAndQuery : UriComponents.AbsoluteUri; + return builder.Uri.GetComponents(components, UriFormat.UriEscaped); + } + + private string GetQueryStringInPaginationLink(int pageOffset, string? pageSizeValue) + { + Dictionary parameters = HttpContext.Request.Query.ToDictionary(pair => pair.Key, pair => (string?)pair.Value.ToString()); + + if (pageSizeValue == null) + { + parameters.Remove(PageSizeParameterName); + } + else + { + parameters[PageSizeParameterName] = pageSizeValue; + } + + if (pageOffset == 1) + { + parameters.Remove(PageNumberParameterName); + } + else + { + parameters[PageNumberParameterName] = pageOffset.ToString(); + } + + return QueryString.Create(parameters).Value ?? string.Empty; + } + + /// + public ResourceLinks? GetResourceLinks(ResourceType resourceType, IIdentifiable resource) + { + ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(resource); + + var links = new ResourceLinks(); + + if (ShouldIncludeResourceLink(LinkTypes.Self, resourceType)) + { + links.Self = GetLinkForResourceSelf(resourceType, resource); + } + + return links.HasValue() ? links : null; + } + + /// + /// Checks if the resource object level should be added by first checking configuration on the , + /// and if not configured, by checking with the global configuration in . + /// + private bool ShouldIncludeResourceLink(LinkTypes linkType, ResourceType resourceType) + { + if (resourceType.ResourceLinks != LinkTypes.NotConfigured) + { + return resourceType.ResourceLinks.HasFlag(linkType); + } + + return _options.ResourceLinks.HasFlag(linkType); + } + + private string? GetLinkForResourceSelf(ResourceType resourceType, IIdentifiable resource) + { + string? controllerName = _controllerResourceMapping.GetControllerNameForResourceType(resourceType); + RouteValueDictionary routeValues = GetRouteValues(resource.StringId!, null); + + return RenderLinkForAction(controllerName, GetPrimaryControllerActionName, routeValues); + } + + /// + public RelationshipLinks? GetRelationshipLinks(RelationshipAttribute relationship, IIdentifiable leftResource) + { + ArgumentNullException.ThrowIfNull(relationship); + ArgumentNullException.ThrowIfNull(leftResource); + + var links = new RelationshipLinks(); + + if (ShouldIncludeRelationshipLink(LinkTypes.Self, relationship)) + { + links.Self = GetLinkForRelationshipSelf(leftResource.StringId!, relationship); + } + + if (ShouldIncludeRelationshipLink(LinkTypes.Related, relationship)) + { + links.Related = GetLinkForRelationshipRelated(leftResource.StringId!, relationship); + } + + return links.HasValue() ? links : null; + } + + private string? GetLinkForRelationshipSelf(string leftId, RelationshipAttribute relationship) + { + string? controllerName = _controllerResourceMapping.GetControllerNameForResourceType(relationship.LeftType); + RouteValueDictionary routeValues = GetRouteValues(leftId, relationship.PublicName); + + return RenderLinkForAction(controllerName, GetRelationshipControllerActionName, routeValues); + } + + private string? GetLinkForRelationshipRelated(string leftId, RelationshipAttribute relationship) + { + string? controllerName = _controllerResourceMapping.GetControllerNameForResourceType(relationship.LeftType); + RouteValueDictionary routeValues = GetRouteValues(leftId, relationship.PublicName); + + return RenderLinkForAction(controllerName, GetSecondaryControllerActionName, routeValues); + } + + 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, + // so users must override RenderLinkForAction to supply them, if applicable. + RouteValueDictionary routeValues = HttpContext.Request.RouteValues; + + routeValues["id"] = primaryId; + routeValues["relationshipName"] = relationshipName; + + return routeValues; + } + + 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 + // included resources of a different resource type: it should hide its links when there's no controller for them. + return null; + } + + return _options.UseRelativeLinks + ? _linkGenerator.GetPathByAction(HttpContext, actionName, controllerName, routeValues) + : _linkGenerator.GetUriByAction(HttpContext, actionName, controllerName, routeValues); + } + + /// + /// Checks if the relationship object level should be added by first checking configuration on the + /// attribute, if not configured by checking on the resource + /// type that contains this relationship, and if not configured by checking with the global configuration in . + /// + private bool ShouldIncludeRelationshipLink(LinkTypes linkType, RelationshipAttribute relationship) + { + if (relationship.Links != LinkTypes.NotConfigured) + { + return relationship.Links.HasFlag(linkType); + } + + if (relationship.LeftType.RelationshipLinks != LinkTypes.NotConfigured) + { + return relationship.LeftType.RelationshipLinks.HasFlag(linkType); + } + + return _options.RelationshipLinks.HasFlag(linkType); + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs new file mode 100644 index 0000000000..1c3cc604e3 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Response/MetaBuilder.cs @@ -0,0 +1,55 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; + +namespace JsonApiDotNetCore.Serialization.Response; + +/// +[PublicAPI] +public sealed class MetaBuilder : IMetaBuilder +{ + private readonly IPaginationContext _paginationContext; + private readonly IJsonApiOptions _options; + private readonly IResponseMeta _responseMeta; + + private Dictionary _meta = []; + + public MetaBuilder(IPaginationContext paginationContext, IJsonApiOptions options, IResponseMeta responseMeta) + { + ArgumentNullException.ThrowIfNull(paginationContext); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(responseMeta); + + _paginationContext = paginationContext; + _options = options; + _responseMeta = responseMeta; + } + + /// + public void Add(IDictionary values) + { + ArgumentNullException.ThrowIfNull(values); + + _meta = values.Keys.Union(_meta.Keys).ToDictionary(key => key, key => values.TryGetValue(key, out object? value) ? value : _meta[key]); + } + + /// + public IDictionary? Build() + { + if (_paginationContext.TotalResourceCount != null) + { + const string keyName = "Total"; + string key = _options.SerializerOptions.DictionaryKeyPolicy == null ? keyName : _options.SerializerOptions.DictionaryKeyPolicy.ConvertName(keyName); + _meta.Add(key, _paginationContext.TotalResourceCount); + } + + IDictionary? extraMeta = _responseMeta.GetMeta(); + + if (extraMeta != null) + { + Add(extraMeta); + } + + 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 new file mode 100644 index 0000000000..2ded4ae896 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Response/ResourceObjectTreeNode.cs @@ -0,0 +1,287 @@ +using System.Text; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Response; + +/// +/// Represents a dependency tree of resource objects. It provides the values for 'data' and 'included' in the response body. The tree is built by +/// recursively walking the resource relationships from the inclusion chains. Note that a subsequent chain may add additional relationships to a resource +/// object that was produced by an earlier chain. Afterwards, this tree is used to fill relationship objects in the resource objects (depending on sparse +/// fieldsets) and to emit all entries in relationship declaration order. +/// +internal sealed class ResourceObjectTreeNode : IEquatable +{ + // Placeholder root node for the tree, which is never emitted itself. + 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'. + private List? _directChildren; + + // Related resource objects per relationship. These are emitted in 'included'. + private Dictionary>? _childrenByRelationship; + + private bool IsTreeRoot => RootType.Equals(ResourceType); + + // The resource this node was built for. We only store it for the LinkBuilder. + public IIdentifiable Resource { get; } + + // The resource type. We use its relationships to maintain order. + public ResourceType ResourceType { get; } + + // The produced resource object from Resource. For each resource, at most one ResourceObject and one tree node must exist. + public ResourceObject ResourceObject { get; } + + public ResourceObjectTreeNode(IIdentifiable resource, ResourceType resourceType, ResourceObject resourceObject) + { + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(resourceType); + ArgumentNullException.ThrowIfNull(resourceObject); + + Resource = resource; + ResourceType = resourceType; + ResourceObject = resourceObject; + } + + public static ResourceObjectTreeNode CreateRoot() + { + return new ResourceObjectTreeNode(RootResource, RootType, new ResourceObject()); + } + + public void AttachDirectChild(ResourceObjectTreeNode treeNode) + { + ArgumentNullException.ThrowIfNull(treeNode); + + _directChildren ??= []; + _directChildren.Add(treeNode); + } + + public void EnsureHasRelationship(RelationshipAttribute relationship) + { + ArgumentNullException.ThrowIfNull(relationship); + + _childrenByRelationship ??= []; + + if (!_childrenByRelationship.ContainsKey(relationship)) + { + _childrenByRelationship[relationship] = []; + } + } + + public void AttachRelationshipChild(RelationshipAttribute relationship, ResourceObjectTreeNode rightNode) + { + ArgumentNullException.ThrowIfNull(relationship); + ArgumentNullException.ThrowIfNull(rightNode); + + if (_childrenByRelationship == null) + { + throw new InvalidOperationException("Call EnsureHasRelationship() first."); + } + + HashSet rightNodes = _childrenByRelationship[relationship]; + rightNodes.Add(rightNode); + } + + /// + /// Recursively walks the tree and returns the set of unique nodes. Uses relationship declaration order. + /// + public IReadOnlySet GetUniqueNodes() + { + AssertIsTreeRoot(); + + HashSet visited = []; + + VisitSubtree(this, visited); + + return visited.AsReadOnly(); + } + + private static void VisitSubtree(ResourceObjectTreeNode treeNode, ISet visited) + { + if (visited.Contains(treeNode)) + { + return; + } + + if (!treeNode.IsTreeRoot) + { + visited.Add(treeNode); + } + + VisitDirectChildrenInSubtree(treeNode, visited); + VisitRelationshipChildrenInSubtree(treeNode, visited); + } + + private static void VisitDirectChildrenInSubtree(ResourceObjectTreeNode treeNode, ISet visited) + { + if (treeNode._directChildren != null) + { + foreach (ResourceObjectTreeNode child in treeNode._directChildren) + { + VisitSubtree(child, visited); + } + } + } + + private static void VisitRelationshipChildrenInSubtree(ResourceObjectTreeNode treeNode, ISet visited) + { + if (treeNode._childrenByRelationship != null) + { + foreach (RelationshipAttribute relationship in treeNode.ResourceType.Relationships) + { + if (treeNode._childrenByRelationship.TryGetValue(relationship, out HashSet? rightNodes)) + { + VisitRelationshipChildInSubtree(rightNodes, visited); + } + } + } + } + + private static void VisitRelationshipChildInSubtree(HashSet rightNodes, ISet visited) + { + foreach (ResourceObjectTreeNode rightNode in rightNodes) + { + VisitSubtree(rightNode, visited); + } + } + + public IReadOnlySet? GetRightNodesInRelationship(RelationshipAttribute relationship) + { + return _childrenByRelationship != null && _childrenByRelationship.TryGetValue(relationship, out HashSet? rightNodes) + ? rightNodes.AsReadOnly() + : null; + } + + /// + /// Provides the value for 'data' in the response body. Uses relationship declaration order. + /// + public IReadOnlyList GetResponseData() + { + AssertIsTreeRoot(); + + return GetDirectChildren().Select(child => child.ResourceObject).ToArray().AsReadOnly(); + } + + /// + /// Provides the value for 'included' in the response body. Uses relationship declaration order. + /// +#pragma warning disable AV1130 // Return type in method signature should be an interface to an unchangeable collection + public IList GetResponseIncluded() +#pragma warning restore AV1130 // Return type in method signature should be an interface to an unchangeable collection + { + AssertIsTreeRoot(); + + HashSet visited = []; + + foreach (ResourceObjectTreeNode child in GetDirectChildren()) + { + VisitRelationshipChildrenInSubtree(child, visited); + } + + 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() + { + return _directChildren == null ? Array.Empty() : _directChildren; + } + + private void AssertIsTreeRoot() + { + if (!IsTreeRoot) + { + throw new InvalidOperationException("Internal error: this method should only be called from the root of the tree."); + } + } + + public bool Equals(ResourceObjectTreeNode? other) + { + if (other is null) + { + return false; + } + + if (ReferenceEquals(this, other)) + { + return true; + } + + return ResourceObjectComparer.Instance.Equals(ResourceObject, other.ResourceObject); + } + + public override bool Equals(object? other) + { + return Equals(other as ResourceObjectTreeNode); + } + + public override int GetHashCode() + { + return ResourceObject.GetHashCode(); + } + + public override string ToString() + { + var builder = new StringBuilder(); + builder.Append(IsTreeRoot ? ResourceType.PublicName : $"{ResourceObject.Type}:{ResourceObject.Id}"); + + if (_directChildren != null) + { + builder.Append($", children: {_directChildren.Count}"); + } + else if (_childrenByRelationship != null) + { + builder.Append($", children: {string.Join(',', _childrenByRelationship.Select(pair => $"{pair.Key.PublicName} ({pair.Value.Count})"))}"); + } + + return builder.ToString(); + } + + private sealed class EmptyResource : IIdentifiable + { + public string? StringId { get; set; } + public string? LocalId { get; set; } + } + + private sealed class ResourceObjectComparer : IEqualityComparer + { + public static readonly ResourceObjectComparer Instance = new(); + + private ResourceObjectComparer() + { + } + + public bool Equals(ResourceObject? left, ResourceObject? right) + { + if (ReferenceEquals(left, right)) + { + return true; + } + + if (left is null || right is null || left.GetType() != right.GetType()) + { + return false; + } + + return left.Type == right.Type && left.Id == right.Id && left.Lid == right.Lid; + } + + public int GetHashCode(ResourceObject obj) + { + return HashCode.Combine(obj.Type, obj.Id, obj.Lid); + } + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs new file mode 100644 index 0000000000..80f7809255 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Response/ResponseModelAdapter.cs @@ -0,0 +1,411 @@ +using System.Collections.Immutable; +using System.Text.Json.Serialization; +using JetBrains.Annotations; +using JsonApiDotNetCore.AtomicOperations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Errors; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Queries.Expressions; +using JsonApiDotNetCore.QueryStrings; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.Objects; + +namespace JsonApiDotNetCore.Serialization.Response; + +/// +[PublicAPI] +public class ResponseModelAdapter : IResponseModelAdapter +{ + private readonly IJsonApiRequest _request; + private readonly IJsonApiOptions _options; + private readonly ILinkBuilder _linkBuilder; + private readonly IMetaBuilder _metaBuilder; + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; + private readonly IEvaluatedIncludeCache _evaluatedIncludeCache; + private readonly IRequestQueryStringAccessor _requestQueryStringAccessor; + private readonly ISparseFieldSetCache _sparseFieldSetCache; + + // Ensures that at most one ResourceObject (and one tree node) is produced per resource instance. + private readonly Dictionary _resourceToTreeNodeCache = new(IdentifiableComparer.Instance); + + public ResponseModelAdapter(IJsonApiRequest request, IJsonApiOptions options, ILinkBuilder linkBuilder, IMetaBuilder metaBuilder, + IResourceDefinitionAccessor resourceDefinitionAccessor, IEvaluatedIncludeCache evaluatedIncludeCache, ISparseFieldSetCache sparseFieldSetCache, + IRequestQueryStringAccessor 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; + _linkBuilder = linkBuilder; + _metaBuilder = metaBuilder; + _resourceDefinitionAccessor = resourceDefinitionAccessor; + _evaluatedIncludeCache = evaluatedIncludeCache; + _sparseFieldSetCache = sparseFieldSetCache; + _requestQueryStringAccessor = requestQueryStringAccessor; + } + + /// + public Document Convert(object? model) + { + _sparseFieldSetCache.Reset(); + _resourceToTreeNodeCache.Clear(); + + var document = new Document(); + + IncludeExpression? include = _evaluatedIncludeCache.Get(); + IImmutableSet includeElements = include?.Elements ?? ImmutableHashSet.Empty; + + var rootNode = ResourceObjectTreeNode.CreateRoot(); + + if (model is IEnumerable resources) + { + ResourceType resourceType = (_request.SecondaryResourceType ?? _request.PrimaryResourceType)!; + + foreach (IIdentifiable resource in resources) + { + TraverseResource(resource, resourceType, _request.Kind, includeElements, rootNode, null); + } + + PopulateRelationshipsInTree(rootNode, _request.Kind); + + IReadOnlyList resourceObjects = rootNode.GetResponseData(); + document.Data = new SingleOrManyData(resourceObjects); + } + else if (model is IIdentifiable resource) + { + ResourceType resourceType = (_request.SecondaryResourceType ?? _request.PrimaryResourceType)!; + + TraverseResource(resource, resourceType, _request.Kind, includeElements, rootNode, null); + PopulateRelationshipsInTree(rootNode, _request.Kind); + + ResourceObject resourceObject = rootNode.GetResponseData().Single(); + document.Data = new SingleOrManyData(resourceObject); + } + else if (model == null) + { + document.Data = new SingleOrManyData(null); + } + else if (model is IEnumerable operations) + { + using var _ = new RevertRequestStateOnDispose(_request, null); + document.Results = operations.Select(operation => ConvertOperation(operation, includeElements)).ToList(); + } + else if (model is IEnumerable errorObjects) + { + document.Errors = errorObjects.ToList(); + } + else if (model is ErrorObject errorObject) + { + List errors = [errorObject]; + document.Errors = errors; + } + else + { + throw new InvalidOperationException("Data being returned must be resources, operations, errors or null."); + } + + document.JsonApi = GetApiObject(); + document.Links = _linkBuilder.GetTopLevelLinks(); + document.Meta = _metaBuilder.Build(); + document.Included = GetIncluded(rootNode); + + return document; + } + + protected virtual AtomicResultObject ConvertOperation(OperationContainer? operation, IImmutableSet includeElements) + { + ArgumentNullException.ThrowIfNull(includeElements); + + ResourceObject? resourceObject = null; + + if (operation != null) + { + _request.CopyFrom(operation.Request); + + ResourceType resourceType = (operation.Request.SecondaryResourceType ?? operation.Request.PrimaryResourceType)!; + var rootNode = ResourceObjectTreeNode.CreateRoot(); + + TraverseResource(operation.Resource, resourceType, operation.Request.Kind, includeElements, rootNode, null); + PopulateRelationshipsInTree(rootNode, operation.Request.Kind); + + resourceObject = rootNode.GetResponseData().Single(); + + _sparseFieldSetCache.Reset(); + _resourceToTreeNodeCache.Clear(); + } + + return new AtomicResultObject + { + Data = resourceObject == null ? default : new SingleOrManyData(resourceObject) + }; + } + + private void TraverseResource(IIdentifiable resource, ResourceType resourceType, EndpointKind kind, IImmutableSet includeElements, + ResourceObjectTreeNode parentTreeNode, RelationshipAttribute? parentRelationship) + { + ResourceObjectTreeNode treeNode = GetOrCreateTreeNode(resource, resourceType, kind); + + if (parentRelationship != null) + { + parentTreeNode.AttachRelationshipChild(parentRelationship, treeNode); + } + else + { + parentTreeNode.AttachDirectChild(treeNode); + } + + if (kind != EndpointKind.Relationship) + { + TraverseRelationships(resource, treeNode, includeElements, kind); + } + } + + private ResourceObjectTreeNode GetOrCreateTreeNode(IIdentifiable resource, ResourceType resourceType, EndpointKind kind) + { + if (!_resourceToTreeNodeCache.TryGetValue(resource, out ResourceObjectTreeNode? treeNode)) + { + // In case of resource inheritance, prefer the derived resource type over the base type. + ResourceType effectiveResourceType = GetEffectiveResourceType(resource, resourceType); + + ResourceObject resourceObject = ConvertResource(resource, effectiveResourceType, kind); + treeNode = new ResourceObjectTreeNode(resource, effectiveResourceType, resourceObject); + + _resourceToTreeNodeCache.Add(resource, treeNode); + } + + return treeNode; + } + + private static ResourceType GetEffectiveResourceType(IIdentifiable resource, ResourceType declaredType) + { + Type runtimeResourceType = resource.GetClrType(); + + if (declaredType.ClrType == runtimeResourceType) + { + return declaredType; + } + + ResourceType? derivedType = declaredType.GetAllConcreteDerivedTypes().FirstOrDefault(type => type.ClrType == runtimeResourceType); + + if (derivedType == null) + { + throw new InvalidConfigurationException($"Type '{runtimeResourceType}' does not exist in the resource graph."); + } + + return derivedType; + } + + protected virtual ResourceObject ConvertResource(IIdentifiable resource, ResourceType resourceType, EndpointKind kind) + { + ArgumentNullException.ThrowIfNull(resource); + ArgumentNullException.ThrowIfNull(resourceType); + + bool isRelationship = kind == EndpointKind.Relationship; + + if (!isRelationship) + { + _resourceDefinitionAccessor.OnSerialize(resource); + } + + var resourceObject = new ResourceObject + { + Type = resourceType.PublicName, + Id = resource.StringId + }; + + if (!isRelationship) + { + IImmutableSet fieldSet = _sparseFieldSetCache.GetSparseFieldSetForSerializer(resourceType); + + resourceObject.Attributes = ConvertAttributes(resource, resourceType, fieldSet); + resourceObject.Links = _linkBuilder.GetResourceLinks(resourceType, resource); + resourceObject.Meta = _resourceDefinitionAccessor.GetMeta(resourceType, resource); + } + + return resourceObject; + } + +#pragma warning disable AV1130 // Return type in method signature should be an interface to an unchangeable collection + protected virtual IDictionary? ConvertAttributes(IIdentifiable resource, ResourceType resourceType, +#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) + { + if (!fieldSet.Contains(attr) || attr.Property.Name == nameof(Identifiable.Id)) + { + continue; + } + + object? value = attr.GetValue(resource); + + if (_options.SerializerOptions.DefaultIgnoreCondition == JsonIgnoreCondition.WhenWritingNull && value == null) + { + continue; + } + + if (_options.SerializerOptions.DefaultIgnoreCondition == JsonIgnoreCondition.WhenWritingDefault && + Equals(value, RuntimeTypeConverter.GetDefaultValue(attr.Property.PropertyType))) + { + continue; + } + + attrMap.Add(attr.PublicName, value); + } + + return attrMap.Count > 0 ? attrMap : null; + } + + private void TraverseRelationships(IIdentifiable leftResource, ResourceObjectTreeNode leftTreeNode, IImmutableSet includeElements, + EndpointKind kind) + { + foreach (IncludeElementExpression includeElement in includeElements) + { + TraverseRelationship(includeElement.Relationship, leftResource, leftTreeNode, includeElement, kind); + } + } + + private void TraverseRelationship(RelationshipAttribute relationship, IIdentifiable leftResource, ResourceObjectTreeNode leftTreeNode, + IncludeElementExpression includeElement, EndpointKind kind) + { + if (!relationship.LeftType.ClrType.IsAssignableFrom(leftTreeNode.ResourceType.ClrType)) + { + // Skipping over relationship that is declared on another derived type. + return; + } + + // In case of resource inheritance, prefer the relationship on derived type over the one on base type. + RelationshipAttribute effectiveRelationship = !leftTreeNode.ResourceType.Equals(relationship.LeftType) + ? 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.Instance.ExtractResources(rightValue); + + leftTreeNode.EnsureHasRelationship(effectiveRelationship); + + foreach (IIdentifiable rightResource in rightResources) + { + TraverseResource(rightResource, effectiveRelationship.RightType, kind, includeElement.Children, leftTreeNode, effectiveRelationship); + } + } + + private void PopulateRelationshipsInTree(ResourceObjectTreeNode rootNode, EndpointKind kind) + { + if (kind != EndpointKind.Relationship) + { + foreach (ResourceObjectTreeNode treeNode in rootNode.GetUniqueNodes()) + { + PopulateRelationshipsInResourceObject(treeNode); + } + } + } + + private void PopulateRelationshipsInResourceObject(ResourceObjectTreeNode treeNode) + { + IImmutableSet fieldSet = _sparseFieldSetCache.GetSparseFieldSetForSerializer(treeNode.ResourceType); + + foreach (RelationshipAttribute relationship in treeNode.ResourceType.Relationships) + { + if (fieldSet.Contains(relationship)) + { + PopulateRelationshipInResourceObject(treeNode, relationship); + } + } + } + + private void PopulateRelationshipInResourceObject(ResourceObjectTreeNode treeNode, RelationshipAttribute relationship) + { + SingleOrManyData data = GetRelationshipData(treeNode, relationship); + RelationshipLinks? links = _linkBuilder.GetRelationshipLinks(relationship, treeNode.Resource); + + if (links != null || data.IsAssigned) + { + var relationshipObject = new RelationshipObject + { + Links = links, + Data = data + }; + + treeNode.ResourceObject.Relationships ??= new Dictionary(); + treeNode.ResourceObject.Relationships.Add(relationship.PublicName, relationshipObject); + } + } + + private static SingleOrManyData GetRelationshipData(ResourceObjectTreeNode treeNode, RelationshipAttribute relationship) + { + IReadOnlySet? rightNodes = treeNode.GetRightNodesInRelationship(relationship); + + if (rightNodes != null) + { + IEnumerable resourceIdentifierObjects = rightNodes.Select(rightNode => new ResourceIdentifierObject + { + Type = rightNode.ResourceType.PublicName, + Id = rightNode.ResourceObject.Id + }); + + return relationship is HasOneAttribute + ? new SingleOrManyData(resourceIdentifierObjects.SingleOrDefault()) + : new SingleOrManyData(resourceIdentifierObjects); + } + + return default; + } + + protected virtual JsonApiObject? GetApiObject() + { + if (!_options.IncludeJsonApiVersion) + { + return null; + } + + var jsonApiObject = new JsonApiObject + { + Version = "1.1" + }; + + if (_request.Kind == EndpointKind.AtomicOperations) + { + jsonApiObject.Ext = new List + { + "https://jsonapi.org/ext/atomic" + }; + } + + return jsonApiObject; + } + + private IList? GetIncluded(ResourceObjectTreeNode rootNode) + { + IList resourceObjects = rootNode.GetResponseIncluded(); + + if (resourceObjects.Count > 0) + { + return resourceObjects; + } + + return _requestQueryStringAccessor.Query.ContainsKey("include") ? Array.Empty() : null; + } +} 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/Serialization/ResponseSerializer.cs b/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs deleted file mode 100644 index bc5fd805c0..0000000000 --- a/src/JsonApiDotNetCore/Serialization/ResponseSerializer.cs +++ /dev/null @@ -1,182 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization.Building; -using JsonApiDotNetCore.Serialization.Objects; -using Newtonsoft.Json; - -namespace JsonApiDotNetCore.Serialization -{ - /// - /// Server serializer implementation of for resources of a specific type. - /// - /// - /// Because in JsonApiDotNetCore every JSON:API request is associated with exactly one resource (the primary resource, see - /// ), the serializer can leverage this information using generics. See - /// for how this is instantiated. - /// - /// - /// Type of the resource associated with the scope of the request for which this serializer is used. - /// - [PublicAPI] - public class ResponseSerializer : BaseSerializer, IJsonApiSerializer - where TResource : class, IIdentifiable - { - private readonly IMetaBuilder _metaBuilder; - private readonly ILinkBuilder _linkBuilder; - private readonly IIncludedResourceObjectBuilder _includedBuilder; - private readonly IFieldsToSerialize _fieldsToSerialize; - private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; - private readonly IJsonApiOptions _options; - private readonly Type _primaryResourceType; - - /// - public string ContentType { get; } = HeaderConstants.MediaType; - - public ResponseSerializer(IMetaBuilder metaBuilder, ILinkBuilder linkBuilder, IIncludedResourceObjectBuilder includedBuilder, - IFieldsToSerialize fieldsToSerialize, IResourceObjectBuilder resourceObjectBuilder, IResourceDefinitionAccessor resourceDefinitionAccessor, - IJsonApiOptions options) - : base(resourceObjectBuilder) - { - ArgumentGuard.NotNull(metaBuilder, nameof(metaBuilder)); - ArgumentGuard.NotNull(linkBuilder, nameof(linkBuilder)); - ArgumentGuard.NotNull(includedBuilder, nameof(includedBuilder)); - ArgumentGuard.NotNull(fieldsToSerialize, nameof(fieldsToSerialize)); - ArgumentGuard.NotNull(resourceDefinitionAccessor, nameof(resourceDefinitionAccessor)); - ArgumentGuard.NotNull(options, nameof(options)); - - _metaBuilder = metaBuilder; - _linkBuilder = linkBuilder; - _includedBuilder = includedBuilder; - _fieldsToSerialize = fieldsToSerialize; - _resourceDefinitionAccessor = resourceDefinitionAccessor; - _options = options; - _primaryResourceType = typeof(TResource); - } - - /// - public string Serialize(object content) - { - if (content == null || content is IIdentifiable) - { - return SerializeSingle((IIdentifiable)content); - } - - if (content is IEnumerable collectionOfIdentifiable) - { - return SerializeMany(collectionOfIdentifiable.ToArray()); - } - - if (content is ErrorDocument errorDocument) - { - return SerializeErrorDocument(errorDocument); - } - - throw new InvalidOperationException("Data being returned must be errors or resources."); - } - - private string SerializeErrorDocument(ErrorDocument errorDocument) - { - return SerializeObject(errorDocument, _options.SerializerSettings, serializer => - { - serializer.ApplyErrorSettings(); - }); - } - - /// - /// Converts a single resource into a serialized . - /// - /// - /// This method is internal instead of private for easier testability. - /// - internal string SerializeSingle(IIdentifiable resource) - { - if (resource != null && _fieldsToSerialize.ShouldSerialize) - { - _resourceDefinitionAccessor.OnSerialize(resource); - } - - IReadOnlyCollection attributes = _fieldsToSerialize.GetAttributes(_primaryResourceType); - IReadOnlyCollection relationships = _fieldsToSerialize.GetRelationships(_primaryResourceType); - - Document document = Build(resource, attributes, relationships); - ResourceObject resourceObject = document.SingleData; - - if (resourceObject != null) - { - resourceObject.Links = _linkBuilder.GetResourceLinks(resourceObject.Type, resourceObject.Id); - } - - AddTopLevelObjects(document); - - return SerializeObject(document, _options.SerializerSettings, serializer => - { - serializer.NullValueHandling = NullValueHandling.Include; - }); - } - - /// - /// Converts a collection of resources into a serialized . - /// - /// - /// This method is internal instead of private for easier testability. - /// - internal string SerializeMany(IReadOnlyCollection resources) - { - if (_fieldsToSerialize.ShouldSerialize) - { - foreach (IIdentifiable resource in resources) - { - _resourceDefinitionAccessor.OnSerialize(resource); - } - } - - IReadOnlyCollection attributes = _fieldsToSerialize.GetAttributes(_primaryResourceType); - IReadOnlyCollection relationships = _fieldsToSerialize.GetRelationships(_primaryResourceType); - - Document document = Build(resources, attributes, relationships); - - foreach (ResourceObject resourceObject in document.ManyData) - { - ResourceLinks links = _linkBuilder.GetResourceLinks(resourceObject.Type, resourceObject.Id); - - if (links == null) - { - break; - } - - resourceObject.Links = links; - } - - AddTopLevelObjects(document); - - return SerializeObject(document, _options.SerializerSettings, serializer => - { - serializer.NullValueHandling = NullValueHandling.Include; - }); - } - - /// - /// Adds top-level objects that are only added to a document in the case of server-side serialization. - /// - private void AddTopLevelObjects(Document document) - { - if (_options.IncludeJsonApiVersion) - { - document.JsonApi = new JsonApiObject - { - Version = "1.1" - }; - } - - document.Links = _linkBuilder.GetTopLevelLinks(); - document.Meta = _metaBuilder.Build(); - document.Included = _includedBuilder.Build(); - } - } -} diff --git a/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs b/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs deleted file mode 100644 index 5ddc248a4d..0000000000 --- a/src/JsonApiDotNetCore/Serialization/ResponseSerializerFactory.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using Microsoft.Extensions.DependencyInjection; - -namespace JsonApiDotNetCore.Serialization -{ - /// - /// A factory class to abstract away the initialization of the serializer from the ASP.NET Core formatter pipeline. - /// - [PublicAPI] - public class ResponseSerializerFactory : IJsonApiSerializerFactory - { - private readonly IServiceProvider _provider; - private readonly IJsonApiRequest _request; - - public ResponseSerializerFactory(IJsonApiRequest request, IRequestScopedServiceProvider provider) - { - ArgumentGuard.NotNull(request, nameof(request)); - ArgumentGuard.NotNull(provider, nameof(provider)); - - _request = request; - _provider = provider; - } - - /// - /// Initializes the server serializer using the associated with the current request. - /// - public IJsonApiSerializer GetSerializer() - { - if (_request.Kind == EndpointKind.AtomicOperations) - { - return (IJsonApiSerializer)_provider.GetRequiredService(typeof(AtomicOperationsResponseSerializer)); - } - - Type targetType = GetDocumentType(); - - Type serializerType = typeof(ResponseSerializer<>).MakeGenericType(targetType); - object serializer = _provider.GetRequiredService(serializerType); - - return (IJsonApiSerializer)serializer; - } - - private Type GetDocumentType() - { - ResourceContext resourceContext = _request.SecondaryResource ?? _request.PrimaryResource; - return resourceContext.ResourceType; - } - } -} diff --git a/src/JsonApiDotNetCore/Services/AsyncCollectionExtensions.cs b/src/JsonApiDotNetCore/Services/AsyncCollectionExtensions.cs index e93b294a22..83ba8902a2 100644 --- a/src/JsonApiDotNetCore/Services/AsyncCollectionExtensions.cs +++ b/src/JsonApiDotNetCore/Services/AsyncCollectionExtensions.cs @@ -1,36 +1,32 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; -namespace JsonApiDotNetCore.Services +namespace JsonApiDotNetCore.Services; + +[PublicAPI] +public static class AsyncCollectionExtensions { - [PublicAPI] - public static class AsyncCollectionExtensions + public static async Task AddRangeAsync(this ICollection source, IAsyncEnumerable elementsToAdd, CancellationToken cancellationToken = default) { - 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)) - { - source.Add(missingResource); - } - } - - public static async Task> ToListAsync(this IAsyncEnumerable source, CancellationToken cancellationToken = default) + await foreach (T missingResource in elementsToAdd.WithCancellation(cancellationToken)) { - ArgumentGuard.NotNull(source, nameof(source)); + source.Add(missingResource); + } + } - var list = new List(); + public static async Task> ToListAsync(this IAsyncEnumerable source, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(source); - await foreach (T element in source.WithCancellation(cancellationToken)) - { - list.Add(element); - } + List list = []; - return list; + await foreach (T element in source.WithCancellation(cancellationToken)) + { + list.Add(element); } + + return list; } } diff --git a/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs b/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs index 5bbc74ae71..07c9234513 100644 --- a/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IAddToRelationshipService.cs @@ -1,40 +1,31 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; +using System.Diagnostics.CodeAnalysis; using JetBrains.Annotations; using JsonApiDotNetCore.Resources; // ReSharper disable UnusedTypeParameter -namespace JsonApiDotNetCore.Services -{ - /// - public interface IAddToRelationshipService : IAddToRelationshipService - where TResource : class, IIdentifiable - { - } +namespace JsonApiDotNetCore.Services; - /// - [PublicAPI] - public interface IAddToRelationshipService - where TResource : class, IIdentifiable - { - /// - /// Handles a JSON:API request to add resources to a to-many relationship. - /// - /// - /// The identifier of the primary resource. - /// - /// - /// The relationship to add resources to. - /// - /// - /// The set of resources to add to the relationship. - /// - /// - /// Propagates notification that request handling should be canceled. - /// - Task AddToToManyRelationshipAsync(TId primaryId, string relationshipName, ISet secondaryResourceIds, - CancellationToken cancellationToken); - } +/// +[PublicAPI] +public interface IAddToRelationshipService + where TResource : class, IIdentifiable +{ + /// + /// Handles a JSON:API request to add resources to a to-many relationship. + /// + /// + /// Identifies the left side of the relationship. + /// + /// + /// The relationship to add resources to. + /// + /// + /// The set of resources to add to the relationship. + /// + /// + /// Propagates notification that request handling should be canceled. + /// + Task AddToToManyRelationshipAsync([DisallowNull] TId leftId, string relationshipName, ISet rightResourceIds, + CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/Services/ICreateService.cs b/src/JsonApiDotNetCore/Services/ICreateService.cs index af735de513..7a75d6d4af 100644 --- a/src/JsonApiDotNetCore/Services/ICreateService.cs +++ b/src/JsonApiDotNetCore/Services/ICreateService.cs @@ -1,22 +1,13 @@ -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Services -{ - /// - public interface ICreateService : ICreateService - where TResource : class, IIdentifiable - { - } +namespace JsonApiDotNetCore.Services; - /// - public interface ICreateService - where TResource : class, IIdentifiable - { - /// - /// Handles a JSON:API request to create a new resource with attributes, relationships or both. - /// - Task CreateAsync(TResource resource, CancellationToken cancellationToken); - } +/// +public interface ICreateService + where TResource : class, IIdentifiable +{ + /// + /// Handles a JSON:API request to create a new resource with attributes, relationships or both. + /// + Task CreateAsync(TResource resource, CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/Services/IDeleteService.cs b/src/JsonApiDotNetCore/Services/IDeleteService.cs index b3a801208d..375181e529 100644 --- a/src/JsonApiDotNetCore/Services/IDeleteService.cs +++ b/src/JsonApiDotNetCore/Services/IDeleteService.cs @@ -1,24 +1,16 @@ -using System.Threading; -using System.Threading.Tasks; +using System.Diagnostics.CodeAnalysis; using JsonApiDotNetCore.Resources; // ReSharper disable UnusedTypeParameter -namespace JsonApiDotNetCore.Services -{ - /// - public interface IDeleteService : IDeleteService - where TResource : class, IIdentifiable - { - } +namespace JsonApiDotNetCore.Services; - /// - public interface IDeleteService - where TResource : class, IIdentifiable - { - /// - /// Handles a JSON:API request to delete an existing resource. - /// - Task DeleteAsync(TId id, CancellationToken cancellationToken); - } +/// +public interface IDeleteService + where TResource : class, IIdentifiable +{ + /// + /// Handles a JSON:API request to delete an existing resource. + /// + Task DeleteAsync([DisallowNull] TId id, CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/Services/IGetAllService.cs b/src/JsonApiDotNetCore/Services/IGetAllService.cs index bab5aeab31..4c6b1d59c4 100644 --- a/src/JsonApiDotNetCore/Services/IGetAllService.cs +++ b/src/JsonApiDotNetCore/Services/IGetAllService.cs @@ -1,23 +1,13 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Services -{ - /// - public interface IGetAllService : IGetAllService - where TResource : class, IIdentifiable - { - } +namespace JsonApiDotNetCore.Services; - /// - public interface IGetAllService - where TResource : class, IIdentifiable - { - /// - /// Handles a JSON:API request to retrieve a collection of resources for a primary endpoint. - /// - Task> GetAsync(CancellationToken cancellationToken); - } +/// +public interface IGetAllService + where TResource : class, IIdentifiable +{ + /// + /// Handles a JSON:API request to retrieve a collection of resources for a primary endpoint. + /// + Task> GetAsync(CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/Services/IGetByIdService.cs b/src/JsonApiDotNetCore/Services/IGetByIdService.cs index d383cf7afc..fc95e0af1e 100644 --- a/src/JsonApiDotNetCore/Services/IGetByIdService.cs +++ b/src/JsonApiDotNetCore/Services/IGetByIdService.cs @@ -1,22 +1,14 @@ -using System.Threading; -using System.Threading.Tasks; +using System.Diagnostics.CodeAnalysis; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Services -{ - /// - public interface IGetByIdService : IGetByIdService - where TResource : class, IIdentifiable - { - } +namespace JsonApiDotNetCore.Services; - /// - public interface IGetByIdService - where TResource : class, IIdentifiable - { - /// - /// Handles a JSON:API request to retrieve a single resource for a primary endpoint. - /// - Task GetAsync(TId id, CancellationToken cancellationToken); - } +/// +public interface IGetByIdService + where TResource : class, IIdentifiable +{ + /// + /// Handles a JSON:API request to retrieve a single resource for a primary endpoint. + /// + Task GetAsync([DisallowNull] TId id, CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/Services/IGetRelationshipService.cs b/src/JsonApiDotNetCore/Services/IGetRelationshipService.cs index 191457172d..34b9880bea 100644 --- a/src/JsonApiDotNetCore/Services/IGetRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IGetRelationshipService.cs @@ -1,24 +1,16 @@ -using System.Threading; -using System.Threading.Tasks; +using System.Diagnostics.CodeAnalysis; using JsonApiDotNetCore.Resources; // ReSharper disable UnusedTypeParameter -namespace JsonApiDotNetCore.Services -{ - /// - public interface IGetRelationshipService : IGetRelationshipService - where TResource : class, IIdentifiable - { - } +namespace JsonApiDotNetCore.Services; - /// - public interface IGetRelationshipService - where TResource : class, IIdentifiable - { - /// - /// Handles a JSON:API request to retrieve a single relationship. - /// - Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken); - } +/// +public interface IGetRelationshipService + where TResource : class, IIdentifiable +{ + /// + /// Handles a JSON:API request to retrieve a single relationship. + /// + Task GetRelationshipAsync([DisallowNull] TId id, string relationshipName, CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/Services/IGetSecondaryService.cs b/src/JsonApiDotNetCore/Services/IGetSecondaryService.cs index 949de5a5ac..33d47db454 100644 --- a/src/JsonApiDotNetCore/Services/IGetSecondaryService.cs +++ b/src/JsonApiDotNetCore/Services/IGetSecondaryService.cs @@ -1,25 +1,17 @@ -using System.Threading; -using System.Threading.Tasks; +using System.Diagnostics.CodeAnalysis; using JsonApiDotNetCore.Resources; // ReSharper disable UnusedTypeParameter -namespace JsonApiDotNetCore.Services -{ - /// - public interface IGetSecondaryService : IGetSecondaryService - where TResource : class, IIdentifiable - { - } +namespace JsonApiDotNetCore.Services; - /// - public interface IGetSecondaryService - where TResource : class, IIdentifiable - { - /// - /// 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); - } +/// +public interface IGetSecondaryService + where TResource : class, IIdentifiable +{ + /// + /// 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([DisallowNull] TId id, string relationshipName, CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs b/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs index c322732cc7..d3844610c7 100644 --- a/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/IRemoveFromRelationshipService.cs @@ -1,38 +1,29 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; +using System.Diagnostics.CodeAnalysis; using JsonApiDotNetCore.Resources; // ReSharper disable UnusedTypeParameter -namespace JsonApiDotNetCore.Services -{ - /// - public interface IRemoveFromRelationshipService : IRemoveFromRelationshipService - where TResource : class, IIdentifiable - { - } +namespace JsonApiDotNetCore.Services; - /// - public interface IRemoveFromRelationshipService - where TResource : class, IIdentifiable - { - /// - /// Handles a JSON:API request to remove resources from a to-many relationship. - /// - /// - /// The identifier of the primary resource. - /// - /// - /// The relationship to remove resources from. - /// - /// - /// The set of resources to remove from the relationship. - /// - /// - /// Propagates notification that request handling should be canceled. - /// - Task RemoveFromToManyRelationshipAsync(TId primaryId, string relationshipName, ISet secondaryResourceIds, - CancellationToken cancellationToken); - } +/// +public interface IRemoveFromRelationshipService + where TResource : class, IIdentifiable +{ + /// + /// Handles a JSON:API request to remove resources from a to-many relationship. + /// + /// + /// Identifies the left side of the relationship. + /// + /// + /// The relationship to remove resources from. + /// + /// + /// The set of resources to remove from the relationship. + /// + /// + /// Propagates notification that request handling should be canceled. + /// + 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 334fdb26fa..7bc47b6c20 100644 --- a/src/JsonApiDotNetCore/Services/IResourceCommandService.cs +++ b/src/JsonApiDotNetCore/Services/IResourceCommandService.cs @@ -1,33 +1,17 @@ using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Services -{ - /// - /// Groups write operations. - /// - /// - /// The resource type. - /// - public interface IResourceCommandService - : ICreateService, IAddToRelationshipService, IUpdateService, ISetRelationshipService, - IDeleteService, IRemoveFromRelationshipService, IResourceCommandService - where TResource : class, IIdentifiable - { - } +namespace JsonApiDotNetCore.Services; - /// - /// Groups write operations. - /// - /// - /// The resource type. - /// - /// - /// The resource identifier type. - /// - public interface IResourceCommandService - : ICreateService, IAddToRelationshipService, IUpdateService, ISetRelationshipService, - IDeleteService, IRemoveFromRelationshipService - where TResource : class, IIdentifiable - { - } -} +/// +/// Groups write operations. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +public interface IResourceCommandService + : ICreateService, IAddToRelationshipService, IUpdateService, ISetRelationshipService, + IDeleteService, IRemoveFromRelationshipService + where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/Services/IResourceQueryService.cs b/src/JsonApiDotNetCore/Services/IResourceQueryService.cs index 07c89e8643..b2de9b03fc 100644 --- a/src/JsonApiDotNetCore/Services/IResourceQueryService.cs +++ b/src/JsonApiDotNetCore/Services/IResourceQueryService.cs @@ -1,32 +1,16 @@ using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Services -{ - /// - /// Groups read operations. - /// - /// - /// The resource type. - /// - public interface IResourceQueryService - : IGetAllService, IGetByIdService, IGetRelationshipService, IGetSecondaryService, - IResourceQueryService - where TResource : class, IIdentifiable - { - } +namespace JsonApiDotNetCore.Services; - /// - /// Groups read operations. - /// - /// - /// The resource type. - /// - /// - /// The resource identifier type. - /// - public interface IResourceQueryService - : IGetAllService, IGetByIdService, IGetRelationshipService, IGetSecondaryService - where TResource : class, IIdentifiable - { - } -} +/// +/// Groups read operations. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +public interface IResourceQueryService + : IGetAllService, IGetByIdService, IGetRelationshipService, IGetSecondaryService + where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/Services/IResourceService.cs b/src/JsonApiDotNetCore/Services/IResourceService.cs index eb1a744c1b..2a75be7151 100644 --- a/src/JsonApiDotNetCore/Services/IResourceService.cs +++ b/src/JsonApiDotNetCore/Services/IResourceService.cs @@ -1,29 +1,15 @@ using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Services -{ - /// - /// Represents the foundational Resource Service layer in the JsonApiDotNetCore architecture that uses a Resource Repository for data access. - /// - /// - /// The resource type. - /// - public interface IResourceService : IResourceCommandService, IResourceQueryService, IResourceService - where TResource : class, IIdentifiable - { - } +namespace JsonApiDotNetCore.Services; - /// - /// Represents the foundational Resource Service layer in the JsonApiDotNetCore architecture that uses a Resource Repository for data access. - /// - /// - /// The resource type. - /// - /// - /// The resource identifier type. - /// - public interface IResourceService : IResourceCommandService, IResourceQueryService - where TResource : class, IIdentifiable - { - } -} +/// +/// Represents the foundational Resource Service layer in the JsonApiDotNetCore architecture that uses a Resource Repository for data access. +/// +/// +/// The resource type. +/// +/// +/// The resource identifier type. +/// +public interface IResourceService : IResourceCommandService, IResourceQueryService + where TResource : class, IIdentifiable; diff --git a/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs b/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs index 28904f1a28..05e8c8c606 100644 --- a/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs +++ b/src/JsonApiDotNetCore/Services/ISetRelationshipService.cs @@ -1,36 +1,28 @@ -using System.Threading; -using System.Threading.Tasks; +using System.Diagnostics.CodeAnalysis; using JsonApiDotNetCore.Resources; // ReSharper disable UnusedTypeParameter -namespace JsonApiDotNetCore.Services -{ - /// - public interface ISetRelationshipService : ISetRelationshipService - where TResource : class, IIdentifiable - { - } +namespace JsonApiDotNetCore.Services; - /// - public interface ISetRelationshipService - where TResource : class, IIdentifiable - { - /// - /// Handles a JSON:API request to perform a complete replacement of a relationship on an existing resource. - /// - /// - /// The identifier of the primary resource. - /// - /// - /// The relationship for which to perform a complete replacement. - /// - /// - /// The resource or set of resources to assign to the relationship. - /// - /// - /// Propagates notification that request handling should be canceled. - /// - Task SetRelationshipAsync(TId primaryId, string relationshipName, object secondaryResourceIds, CancellationToken cancellationToken); - } +/// +public interface ISetRelationshipService + where TResource : class, IIdentifiable +{ + /// + /// Handles a JSON:API request to perform a complete replacement of a relationship on an existing resource. + /// + /// + /// Identifies the left side of the relationship. + /// + /// + /// The relationship for which to perform a complete replacement. + /// + /// + /// The resource or set of resources to assign to the relationship. + /// + /// + /// Propagates notification that request handling should be canceled. + /// + 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 83e88f6e96..b8349c3909 100644 --- a/src/JsonApiDotNetCore/Services/IUpdateService.cs +++ b/src/JsonApiDotNetCore/Services/IUpdateService.cs @@ -1,23 +1,15 @@ -using System.Threading; -using System.Threading.Tasks; +using System.Diagnostics.CodeAnalysis; using JsonApiDotNetCore.Resources; -namespace JsonApiDotNetCore.Services -{ - /// - public interface IUpdateService : IUpdateService - where TResource : class, IIdentifiable - { - } +namespace JsonApiDotNetCore.Services; - /// - public interface IUpdateService - where TResource : class, IIdentifiable - { - /// - /// 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); - } +/// +public interface IUpdateService + where TResource : class, IIdentifiable +{ + /// + /// 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([DisallowNull] TId id, TResource resource, CancellationToken cancellationToken); } diff --git a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs index 26d0f39a70..de5d3b7b1f 100644 --- a/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs +++ b/src/JsonApiDotNetCore/Services/JsonApiResourceService.cs @@ -1,15 +1,10 @@ -using System; using System.Collections; -using System.Collections.Generic; -using System.Linq; +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; -using System.Threading; -using System.Threading.Tasks; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Diagnostics; using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Hooks; -using JsonApiDotNetCore.Hooks.Internal.Execution; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; @@ -17,524 +12,677 @@ using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using Microsoft.Extensions.Logging; +using SysNotNull = System.Diagnostics.CodeAnalysis.NotNullAttribute; -namespace JsonApiDotNetCore.Services +namespace JsonApiDotNetCore.Services; + +/// +[PublicAPI] +public class JsonApiResourceService : IResourceService + where TResource : class, IIdentifiable { - /// - [PublicAPI] - public class JsonApiResourceService : IResourceService - where TResource : class, IIdentifiable + private readonly IResourceRepositoryAccessor _repositoryAccessor; + private readonly IQueryLayerComposer _queryLayerComposer; + private readonly IPaginationContext _paginationContext; + private readonly IJsonApiOptions _options; + private readonly TraceLogWriter> _traceWriter; + private readonly IJsonApiRequest _request; + private readonly IResourceChangeTracker _resourceChangeTracker; + private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; + + public JsonApiResourceService(IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, + IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, + IResourceDefinitionAccessor resourceDefinitionAccessor) { - private readonly CollectionConverter _collectionConverter = new CollectionConverter(); - private readonly IResourceRepositoryAccessor _repositoryAccessor; - private readonly IQueryLayerComposer _queryLayerComposer; - private readonly IPaginationContext _paginationContext; - private readonly IJsonApiOptions _options; - private readonly TraceLogWriter> _traceWriter; - private readonly IJsonApiRequest _request; - private readonly IResourceChangeTracker _resourceChangeTracker; - private readonly IResourceHookExecutorFacade _hookExecutor; - private readonly IResourceDefinitionAccessor _resourceDefinitionAccessor; - - public JsonApiResourceService(IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, - IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, - IResourceChangeTracker resourceChangeTracker, IResourceHookExecutorFacade hookExecutor) - { - 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(hookExecutor, nameof(hookExecutor)); - - _repositoryAccessor = repositoryAccessor; - _queryLayerComposer = queryLayerComposer; - _paginationContext = paginationContext; - _options = options; - _request = request; - _resourceChangeTracker = resourceChangeTracker; - _hookExecutor = hookExecutor; - _traceWriter = new TraceLogWriter>(loggerFactory); - -#pragma warning disable 612 // Method is obsolete - _resourceDefinitionAccessor = queryLayerComposer.GetResourceDefinitionAccessor(); -#pragma warning restore 612 - } - - /// - public virtual async Task> GetAsync(CancellationToken cancellationToken) - { - _traceWriter.LogMethodStart(); + 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; + _paginationContext = paginationContext; + _options = options; + _request = request; + _resourceChangeTracker = resourceChangeTracker; + _resourceDefinitionAccessor = resourceDefinitionAccessor; + _traceWriter = new TraceLogWriter>(loggerFactory); + } - _hookExecutor.BeforeReadMany(); + /// + public virtual async Task> GetAsync(CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(); - if (_options.IncludeTotalResourceCount) - { - FilterExpression topFilter = _queryLayerComposer.GetTopFilterFromConstraints(_request.PrimaryResource); - _paginationContext.TotalResourceCount = await _repositoryAccessor.CountAsync(topFilter, cancellationToken); + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); - if (_paginationContext.TotalResourceCount == 0) - { - return Array.Empty(); - } - } + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get resources"); - QueryLayer queryLayer = _queryLayerComposer.ComposeFromConstraints(_request.PrimaryResource); - IReadOnlyCollection resources = await _repositoryAccessor.GetAsync(queryLayer, cancellationToken); + if (_options.IncludeTotalResourceCount) + { + FilterExpression? topFilter = _queryLayerComposer.GetPrimaryFilterFromConstraints(_request.PrimaryResourceType); + _paginationContext.TotalResourceCount = await _repositoryAccessor.CountAsync(_request.PrimaryResourceType, topFilter, cancellationToken); - if (queryLayer.Pagination?.PageSize != null && queryLayer.Pagination.PageSize.Value == resources.Count) + if (_paginationContext.TotalResourceCount == 0) { - _paginationContext.IsPageFull = true; + return Array.Empty(); } - - _hookExecutor.AfterReadMany(resources); - return _hookExecutor.OnReturnMany(resources); } - /// - public virtual async Task GetAsync(TId id, CancellationToken cancellationToken) + QueryLayer queryLayer = _queryLayerComposer.ComposeFromConstraints(_request.PrimaryResourceType); + IReadOnlyCollection resources = await _repositoryAccessor.GetAsync(queryLayer, cancellationToken); + + if (queryLayer.Pagination?.PageSize?.Value == resources.Count) { - _traceWriter.LogMethodStart(new - { - id - }); + _paginationContext.IsPageFull = true; + } - _hookExecutor.BeforeReadSingle(id, ResourcePipeline.GetSingle); + return resources; + } - TResource primaryResource = await GetPrimaryResourceByIdAsync(id, TopFieldSelection.PreserveExisting, cancellationToken); + /// + public virtual async Task GetAsync([DisallowNull] TId id, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + id + }); - _hookExecutor.AfterReadSingle(primaryResource, ResourcePipeline.GetSingle); - _hookExecutor.OnReturnSingle(primaryResource, ResourcePipeline.GetSingle); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get single resource"); - return primaryResource; - } + return await GetPrimaryResourceByIdAsync(id, TopFieldSelection.PreserveExisting, cancellationToken); + } - /// - 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 { - _traceWriter.LogMethodStart(new - { - id, - relationshipName - }); - - AssertHasRelationship(_request.Relationship, relationshipName); - - _hookExecutor.BeforeReadSingle(id, ResourcePipeline.GetRelationship); + id, + relationshipName + }); - QueryLayer secondaryLayer = _queryLayerComposer.ComposeFromConstraints(_request.SecondaryResource); - QueryLayer primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResource, id, _request.Relationship); + ArgumentNullException.ThrowIfNull(relationshipName); + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + AssertHasRelationship(_request.Relationship, relationshipName); - IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync(primaryLayer, cancellationToken); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get secondary resource(s)"); - TResource primaryResource = primaryResources.SingleOrDefault(); - AssertPrimaryResourceExists(primaryResource); + if (_options.IncludeTotalResourceCount && _request.IsCollection) + { + await RetrieveResourceCountForNonPrimaryEndpointAsync(id, (HasManyAttribute)_request.Relationship, cancellationToken); - _hookExecutor.AfterReadSingle(primaryResource, ResourcePipeline.GetRelationship); + // We cannot return early when _paginationContext.TotalResourceCount == 0, because we don't know whether + // the parent resource exists. In case the parent does not exist, an error is produced below. + } - object secondaryResourceOrResources = _request.Relationship.GetValue(primaryResource); + QueryLayer secondaryLayer = _queryLayerComposer.ComposeFromConstraints(_request.SecondaryResourceType!); + QueryLayer primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResourceType, id, _request.Relationship); + IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync(primaryLayer, cancellationToken); - if (secondaryResourceOrResources is ICollection secondaryResources && secondaryLayer.Pagination?.PageSize?.Value == secondaryResources.Count) - { - _paginationContext.IsPageFull = true; - } + TResource? primaryResource = primaryResources.SingleOrDefault(); + AssertPrimaryResourceExists(primaryResource); - return _hookExecutor.OnReturnRelationship(secondaryResourceOrResources); - } + object? rightValue = _request.Relationship.GetValue(primaryResource); - /// - public virtual async Task GetRelationshipAsync(TId id, string relationshipName, CancellationToken cancellationToken) + if (rightValue is ICollection rightResources && secondaryLayer.Pagination?.PageSize?.Value == rightResources.Count) { - _traceWriter.LogMethodStart(new - { - id, - relationshipName - }); + _paginationContext.IsPageFull = true; + } - ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); + return rightValue; + } - AssertHasRelationship(_request.Relationship, relationshipName); + /// + public virtual async Task GetRelationshipAsync([DisallowNull] TId id, string relationshipName, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + id, + relationshipName + }); - _hookExecutor.BeforeReadSingle(id, ResourcePipeline.GetRelationship); + ArgumentNullException.ThrowIfNull(relationshipName); + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + AssertHasRelationship(_request.Relationship, relationshipName); - QueryLayer secondaryLayer = _queryLayerComposer.ComposeSecondaryLayerForRelationship(_request.SecondaryResource); - QueryLayer primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResource, id, _request.Relationship); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Get relationship"); - IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync(primaryLayer, cancellationToken); + if (_options.IncludeTotalResourceCount && _request.IsCollection) + { + await RetrieveResourceCountForNonPrimaryEndpointAsync(id, (HasManyAttribute)_request.Relationship, cancellationToken); - TResource primaryResource = primaryResources.SingleOrDefault(); - AssertPrimaryResourceExists(primaryResource); + // We cannot return early when _paginationContext.TotalResourceCount == 0, because we don't know whether + // the parent resource exists. In case the parent does not exist, an error is produced below. + } - _hookExecutor.AfterReadSingle(primaryResource, ResourcePipeline.GetRelationship); + QueryLayer secondaryLayer = _queryLayerComposer.ComposeSecondaryLayerForRelationship(_request.SecondaryResourceType!); + QueryLayer primaryLayer = _queryLayerComposer.WrapLayerForSecondaryEndpoint(secondaryLayer, _request.PrimaryResourceType, id, _request.Relationship); + IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync(primaryLayer, cancellationToken); - object secondaryResourceOrResources = _request.Relationship.GetValue(primaryResource); + TResource? primaryResource = primaryResources.SingleOrDefault(); + AssertPrimaryResourceExists(primaryResource); - return _hookExecutor.OnReturnRelationship(secondaryResourceOrResources); - } + object? rightValue = _request.Relationship.GetValue(primaryResource); - /// - public virtual async Task CreateAsync(TResource resource, CancellationToken cancellationToken) + if (rightValue is ICollection rightResources && secondaryLayer.Pagination?.PageSize?.Value == rightResources.Count) { - _traceWriter.LogMethodStart(new - { - resource - }); - - ArgumentGuard.NotNull(resource, nameof(resource)); + _paginationContext.IsPageFull = true; + } - TResource resourceFromRequest = resource; - _resourceChangeTracker.SetRequestedAttributeValues(resourceFromRequest); + return rightValue; + } - _hookExecutor.BeforeCreate(resourceFromRequest); + private async Task RetrieveResourceCountForNonPrimaryEndpointAsync([DisallowNull] TId id, HasManyAttribute relationship, + CancellationToken cancellationToken) + { + FilterExpression? secondaryFilter = _queryLayerComposer.GetSecondaryFilterFromConstraints(id, relationship); - TResource resourceForDatabase = await _repositoryAccessor.GetForCreateAsync(resource.Id, cancellationToken); + if (secondaryFilter != null) + { + _paginationContext.TotalResourceCount = await _repositoryAccessor.CountAsync(relationship.RightType, secondaryFilter, cancellationToken); + } + } - _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceForDatabase); + /// + public virtual async Task CreateAsync(TResource resource, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new + { + resource + }); - await InitializeResourceAsync(resourceForDatabase, cancellationToken); + ArgumentNullException.ThrowIfNull(resource); - try - { - await _repositoryAccessor.CreateAsync(resourceFromRequest, resourceForDatabase, cancellationToken); - } - catch (DataStoreUpdateException) - { - if (!Equals(resourceFromRequest.Id, default(TId))) - { - TResource existingResource = - await TryGetPrimaryResourceByIdAsync(resourceFromRequest.Id, TopFieldSelection.OnlyIdAttribute, cancellationToken); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Create resource"); - if (existingResource != null) - { - throw new ResourceAlreadyExistsException(resourceFromRequest.StringId, _request.PrimaryResource.PublicName); - } - } + TResource resourceFromRequest = resource; + _resourceChangeTracker.SetRequestAttributeValues(resourceFromRequest); - await AssertResourcesToAssignInRelationshipsExistAsync(resourceFromRequest, cancellationToken); - throw; - } + await AccurizeResourceTypesInHierarchyToAssignInRelationshipsAsync(resourceFromRequest, cancellationToken); - TResource resourceFromDatabase = await GetPrimaryResourceByIdAsync(resourceForDatabase.Id, TopFieldSelection.WithAllAttributes, cancellationToken); + Type resourceClrType = resourceFromRequest.GetClrType(); - _hookExecutor.AfterCreate(resourceFromDatabase); + TResource resourceForDatabase = + await _repositoryAccessor.GetForCreateAsync(resourceClrType, resourceFromRequest.Id!, cancellationToken); - _resourceChangeTracker.SetFinallyStoredAttributeValues(resourceFromDatabase); + AccurizeJsonApiRequest(resourceForDatabase); - bool hasImplicitChanges = _resourceChangeTracker.HasImplicitChanges(); + _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceForDatabase); - if (!hasImplicitChanges) - { - return null; - } + await InitializeResourceAsync(resourceForDatabase, cancellationToken); - _hookExecutor.OnReturnSingle(resourceFromDatabase, ResourcePipeline.Post); - return resourceFromDatabase; + try + { + await _repositoryAccessor.CreateAsync(resourceFromRequest, resourceForDatabase, cancellationToken); } - - protected virtual async Task InitializeResourceAsync(TResource resourceForDatabase, CancellationToken cancellationToken) + catch (DataStoreUpdateException) { - await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceForDatabase, OperationKind.CreateResource, cancellationToken); + await AssertPrimaryResourceDoesNotExistAsync(resourceFromRequest, cancellationToken); + await AssertResourcesToAssignInRelationshipsExistAsync(resourceFromRequest, cancellationToken); + throw; } - protected async Task AssertResourcesToAssignInRelationshipsExistAsync(TResource primaryResource, CancellationToken cancellationToken) - { - var missingResources = new List(); + TResource resourceFromDatabase = await GetPrimaryResourceByIdAsync(resourceForDatabase.Id!, TopFieldSelection.WithAllAttributes, cancellationToken); - foreach ((QueryLayer queryLayer, RelationshipAttribute relationship) in _queryLayerComposer.ComposeForGetTargetedSecondaryResourceIds( - primaryResource)) - { - object rightValue = relationship.GetValue(primaryResource); - ICollection rightResourceIds = _collectionConverter.ExtractResources(rightValue); + _resourceChangeTracker.SetFinallyStoredAttributeValues(resourceFromDatabase); - IAsyncEnumerable missingResourcesInRelationship = - GetMissingRightResourcesAsync(queryLayer, relationship, rightResourceIds, cancellationToken); + bool hasImplicitChanges = _resourceChangeTracker.HasImplicitChanges(); + return hasImplicitChanges ? resourceFromDatabase : null; + } - await missingResources.AddRangeAsync(missingResourcesInRelationship, cancellationToken); - } + 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); - if (missingResources.Any()) + if (existingResource != null) { - throw new ResourcesInRelationshipsNotFoundException(missingResources); + throw new ResourceAlreadyExistsException(resource.StringId!, _request.PrimaryResourceType!.PublicName); } } + } - private async IAsyncEnumerable GetMissingRightResourcesAsync(QueryLayer existingRightResourceIdsQueryLayer, - RelationshipAttribute relationship, ICollection rightResourceIds, [EnumeratorCancellation] CancellationToken cancellationToken) - { - IReadOnlyCollection existingResources = await _repositoryAccessor.GetAsync( - existingRightResourceIdsQueryLayer.ResourceContext.ResourceType, existingRightResourceIdsQueryLayer, cancellationToken); + protected virtual async Task InitializeResourceAsync(TResource resourceForDatabase, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(resourceForDatabase); - string[] existingResourceIds = existingResources.Select(resource => resource.StringId).ToArray(); + await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceForDatabase, WriteOperationKind.CreateResource, cancellationToken); + } + + private async Task AccurizeResourceTypesInHierarchyToAssignInRelationshipsAsync(TResource primaryResource, CancellationToken cancellationToken) + { + await ValidateResourcesToAssignInRelationshipsExistWithRefreshAsync(primaryResource, true, cancellationToken); + } - foreach (IIdentifiable rightResourceId in rightResourceIds) + 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) + { + List missingResources = []; + + foreach ((QueryLayer queryLayer, RelationshipAttribute relationship) in _queryLayerComposer.ComposeForGetTargetedSecondaryResourceIds(primaryResource)) + { + if (!onlyIfTypeHierarchy || relationship.RightType.IsPartOfTypeHierarchy()) { - if (!existingResourceIds.Contains(rightResourceId.StringId)) + object? rightValue = relationship.GetValue(primaryResource); + HashSet rightResourceIds = CollectionConverter.Instance.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); + + if (rightResourceIds.Count > 0) { - yield return new MissingResourceInRelationship(relationship.PublicName, existingRightResourceIdsQueryLayer.ResourceContext.PublicName, - rightResourceId.StringId); + IAsyncEnumerable missingResourcesInRelationship = + GetMissingRightResourcesAsync(queryLayer, relationship, rightResourceIds, cancellationToken); + + await missingResources.AddRangeAsync(missingResourcesInRelationship, cancellationToken); + + // Some of the right-side resources from request may be typed as base types, but stored as derived types. + // 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.Instance.CopyToTypedCollection(rightResourceIds, relationship.Property.PropertyType); + + relationship.SetValue(primaryResource, newRightValue); } } } - /// - public virtual async Task AddToToManyRelationshipAsync(TId primaryId, string relationshipName, ISet secondaryResourceIds, - CancellationToken cancellationToken) + if (missingResources.Count > 0) { - _traceWriter.LogMethodStart(new - { - primaryId, - secondaryResourceIds - }); + throw new ResourcesInRelationshipsNotFoundException(missingResources); + } + } - ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); - ArgumentGuard.NotNull(secondaryResourceIds, nameof(secondaryResourceIds)); + private async IAsyncEnumerable GetMissingRightResourcesAsync(QueryLayer existingRightResourceIdsQueryLayer, + RelationshipAttribute relationship, HashSet rightResourceIds, [EnumeratorCancellation] CancellationToken cancellationToken) + { + IReadOnlyCollection existingResources = await _repositoryAccessor.GetAsync(existingRightResourceIdsQueryLayer.ResourceType, + existingRightResourceIdsQueryLayer, cancellationToken); - AssertHasRelationship(_request.Relationship, relationshipName); + foreach (IIdentifiable rightResourceId in rightResourceIds.ToArray()) + { + Type rightResourceClrType = rightResourceId.GetClrType(); + IIdentifiable? existingResourceId = existingResources.FirstOrDefault(resource => resource.StringId == rightResourceId.StringId); - if (secondaryResourceIds.Any() && _request.Relationship is HasManyThroughAttribute hasManyThrough) + if (existingResourceId != null) { - // 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. - await RemoveExistingIdsFromSecondarySetAsync(primaryId, secondaryResourceIds, hasManyThrough, cancellationToken); - } + Type existingResourceClrType = existingResourceId.GetClrType(); - try - { - await _repositoryAccessor.AddToToManyRelationshipAsync(primaryId, secondaryResourceIds, cancellationToken); - } - catch (DataStoreUpdateException) - { - _ = await GetPrimaryResourceByIdAsync(primaryId, TopFieldSelection.OnlyIdAttribute, cancellationToken); - await AssertRightResourcesExistAsync(secondaryResourceIds, cancellationToken); - throw; + if (rightResourceClrType.IsAssignableFrom(existingResourceClrType)) + { + if (rightResourceClrType != existingResourceClrType) + { + // PERF: As a side effect, we replace the resource base type from request with the derived type that is stored. + rightResourceIds.Remove(rightResourceId); + rightResourceIds.Add(existingResourceId); + } + + continue; + } } + + ResourceType requestResourceType = relationship.RightType.GetTypeOrDerived(rightResourceClrType); + yield return new MissingResourceInRelationship(relationship.PublicName, requestResourceType.PublicName, rightResourceId.StringId!); } + } - private async Task RemoveExistingIdsFromSecondarySetAsync(TId primaryId, ISet secondaryResourceIds, - HasManyThroughAttribute hasManyThrough, CancellationToken cancellationToken) + /// + public virtual async Task AddToToManyRelationshipAsync([DisallowNull] TId leftId, string relationshipName, ISet rightResourceIds, + CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - QueryLayer queryLayer = _queryLayerComposer.ComposeForHasMany(hasManyThrough, primaryId, secondaryResourceIds); - IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync(queryLayer, cancellationToken); + leftId, + relationshipName, + rightResourceIds + }); - TResource primaryResource = primaryResources.FirstOrDefault(); - AssertPrimaryResourceExists(primaryResource); + ArgumentNullException.ThrowIfNull(relationshipName); + ArgumentNullException.ThrowIfNull(rightResourceIds); + AssertHasRelationship(_request.Relationship, relationshipName); - object rightValue = _request.Relationship.GetValue(primaryResource); - ICollection existingRightResourceIds = _collectionConverter.ExtractResources(rightValue); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Add to to-many relationship"); - secondaryResourceIds.ExceptWith(existingRightResourceIds); - } + TResource? resourceFromDatabase = null; - protected async Task AssertRightResourcesExistAsync(object rightResourceIds, CancellationToken cancellationToken) + if (rightResourceIds.Count > 0 && _request.Relationship is HasManyAttribute { IsManyToMany: true } manyToManyRelationship) { - ICollection secondaryResourceIds = _collectionConverter.ExtractResources(rightResourceIds); + // 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. + resourceFromDatabase = await RemoveExistingIdsFromRelationshipRightSideAsync(manyToManyRelationship, leftId, rightResourceIds, cancellationToken); + } - if (secondaryResourceIds.Any()) - { - QueryLayer queryLayer = _queryLayerComposer.ComposeForGetRelationshipRightIds(_request.Relationship, secondaryResourceIds); + if (_request.Relationship.LeftType.IsPartOfTypeHierarchy()) + { + // The left resource may be stored as a derived type. We fetch it, so we'll know the stored type, which + // enables to invoke IResourceDefinition with TResource being the stored resource type. + resourceFromDatabase ??= await GetPrimaryResourceForUpdateAsync(leftId, cancellationToken); + AccurizeJsonApiRequest(resourceFromDatabase); + } - List missingResources = - await GetMissingRightResourcesAsync(queryLayer, _request.Relationship, secondaryResourceIds, cancellationToken) - .ToListAsync(cancellationToken); + ISet effectiveRightResourceIds = rightResourceIds; - if (missingResources.Any()) - { - throw new ResourcesInRelationshipsNotFoundException(missingResources); - } - } + if (_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. + object? rightValue = await AssertRightResourcesExistAsync(rightResourceIds, cancellationToken); + effectiveRightResourceIds = ((IEnumerable)rightValue!).ToHashSet(IdentifiableComparer.Instance); } - /// - public virtual async Task UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken) + try { - _traceWriter.LogMethodStart(new - { - id, - resource - }); + await _repositoryAccessor.AddToToManyRelationshipAsync(resourceFromDatabase, leftId, effectiveRightResourceIds, cancellationToken); + } + catch (DataStoreUpdateException) + { + await GetPrimaryResourceByIdAsync(leftId, TopFieldSelection.OnlyIdAttribute, cancellationToken); + await AssertRightResourcesExistAsync(effectiveRightResourceIds, cancellationToken); + throw; + } + } - ArgumentGuard.NotNull(resource, nameof(resource)); + private async Task RemoveExistingIdsFromRelationshipRightSideAsync(HasManyAttribute hasManyRelationship, [DisallowNull] TId leftId, + ISet rightResourceIds, CancellationToken cancellationToken) + { + TResource leftResource = await GetForHasManyUpdateAsync(hasManyRelationship, leftId, rightResourceIds, cancellationToken); - TResource resourceFromRequest = resource; - _resourceChangeTracker.SetRequestedAttributeValues(resourceFromRequest); + object? rightValue = hasManyRelationship.GetValue(leftResource); + IReadOnlyCollection existingRightResourceIds = CollectionConverter.Instance.ExtractResources(rightValue); - _hookExecutor.BeforeUpdateResource(resourceFromRequest); + rightResourceIds.ExceptWith(existingRightResourceIds); - TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(id, cancellationToken); + return leftResource; + } - _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceFromDatabase); + 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); + AssertPrimaryResourceExists(leftResource); - await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceFromDatabase, OperationKind.UpdateResource, cancellationToken); + return leftResource; + } - try - { - await _repositoryAccessor.UpdateAsync(resourceFromRequest, resourceFromDatabase, cancellationToken); - } - catch (DataStoreUpdateException) - { - await AssertResourcesToAssignInRelationshipsExistAsync(resourceFromRequest, cancellationToken); - throw; - } + protected async Task AssertRightResourcesExistAsync(object? rightValue, CancellationToken cancellationToken) + { + AssertRelationshipInJsonApiRequestIsNotNull(_request.Relationship); - TResource afterResourceFromDatabase = await GetPrimaryResourceByIdAsync(id, TopFieldSelection.WithAllAttributes, cancellationToken); + HashSet rightResourceIds = CollectionConverter.Instance.ExtractResources(rightValue).ToHashSet(IdentifiableComparer.Instance); + object? newRightValue = rightValue; - _hookExecutor.AfterUpdateResource(afterResourceFromDatabase); + if (rightResourceIds.Count > 0) + { + QueryLayer queryLayer = _queryLayerComposer.ComposeForGetRelationshipRightIds(_request.Relationship, rightResourceIds); - _resourceChangeTracker.SetFinallyStoredAttributeValues(afterResourceFromDatabase); + List missingResources = + await GetMissingRightResourcesAsync(queryLayer, _request.Relationship, rightResourceIds, cancellationToken).ToListAsync(cancellationToken); - bool hasImplicitChanges = _resourceChangeTracker.HasImplicitChanges(); + // Some of the right-side resources from request may be typed as base types, but stored as derived types. + // 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.Instance.CopyToTypedCollection(rightResourceIds, _request.Relationship.Property.PropertyType); - if (!hasImplicitChanges) + if (missingResources.Count > 0) { - return null; + throw new ResourcesInRelationshipsNotFoundException(missingResources); } - - _hookExecutor.OnReturnSingle(afterResourceFromDatabase, ResourcePipeline.Patch); - return afterResourceFromDatabase; } - /// - public virtual async Task SetRelationshipAsync(TId primaryId, string relationshipName, object secondaryResourceIds, CancellationToken cancellationToken) + return newRightValue; + } + + /// + public virtual async Task UpdateAsync([DisallowNull] TId id, TResource resource, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - _traceWriter.LogMethodStart(new - { - primaryId, - relationshipName, - secondaryResourceIds - }); + id, + resource + }); - ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); + ArgumentNullException.ThrowIfNull(resource); - AssertHasRelationship(_request.Relationship, relationshipName); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Update resource"); - TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(primaryId, cancellationToken); + TResource resourceFromRequest = resource; + _resourceChangeTracker.SetRequestAttributeValues(resourceFromRequest); - await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceFromDatabase, OperationKind.SetRelationship, cancellationToken); + await AccurizeResourceTypesInHierarchyToAssignInRelationshipsAsync(resourceFromRequest, cancellationToken); - _hookExecutor.BeforeUpdateRelationship(resourceFromDatabase); + TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(id, cancellationToken); + AccurizeJsonApiRequest(resourceFromDatabase); - try - { - await _repositoryAccessor.SetRelationshipAsync(resourceFromDatabase, secondaryResourceIds, cancellationToken); - } - catch (DataStoreUpdateException) - { - await AssertRightResourcesExistAsync(secondaryResourceIds, cancellationToken); - throw; - } + _resourceChangeTracker.SetInitiallyStoredAttributeValues(resourceFromDatabase); - _hookExecutor.AfterUpdateRelationship(resourceFromDatabase); - } + await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceFromDatabase, WriteOperationKind.UpdateResource, cancellationToken); - /// - public virtual async Task DeleteAsync(TId id, CancellationToken cancellationToken) + try { - _traceWriter.LogMethodStart(new - { - id - }); + await _repositoryAccessor.UpdateAsync(resourceFromRequest, resourceFromDatabase, cancellationToken); + } + catch (DataStoreUpdateException) + { + await AssertResourcesToAssignInRelationshipsExistAsync(resourceFromRequest, cancellationToken); + throw; + } - _hookExecutor.BeforeDelete(id); + TResource afterResourceFromDatabase = await GetPrimaryResourceByIdAsync(id, TopFieldSelection.WithAllAttributes, cancellationToken); - try - { - await _repositoryAccessor.DeleteAsync(id, cancellationToken); - } - catch (DataStoreUpdateException) - { - _ = await GetPrimaryResourceByIdAsync(id, TopFieldSelection.OnlyIdAttribute, cancellationToken); - throw; - } + _resourceChangeTracker.SetFinallyStoredAttributeValues(afterResourceFromDatabase); - _hookExecutor.AfterDelete(id); - } + bool hasImplicitChanges = _resourceChangeTracker.HasImplicitChanges(); + return hasImplicitChanges ? afterResourceFromDatabase : null; + } - /// - public virtual async Task RemoveFromToManyRelationshipAsync(TId primaryId, string relationshipName, ISet secondaryResourceIds, - CancellationToken cancellationToken) + /// + public virtual async Task SetRelationshipAsync([DisallowNull] TId leftId, string relationshipName, object? rightValue, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - _traceWriter.LogMethodStart(new - { - primaryId, - relationshipName, - secondaryResourceIds - }); + leftId, + relationshipName, + rightValue + }); - ArgumentGuard.NotNullNorEmpty(relationshipName, nameof(relationshipName)); - ArgumentGuard.NotNull(secondaryResourceIds, nameof(secondaryResourceIds)); + ArgumentNullException.ThrowIfNull(relationshipName); + AssertHasRelationship(_request.Relationship, relationshipName); - AssertHasRelationship(_request.Relationship, relationshipName); + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Service - Set relationship"); - TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(primaryId, cancellationToken); + 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. + ? await AssertRightResourcesExistAsync(rightValue, cancellationToken) + : rightValue; - await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceFromDatabase, OperationKind.RemoveFromRelationship, cancellationToken); + TResource resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(leftId, cancellationToken); + AccurizeJsonApiRequest(resourceFromDatabase); - await AssertRightResourcesExistAsync(secondaryResourceIds, cancellationToken); + await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceFromDatabase, WriteOperationKind.SetRelationship, cancellationToken); - await _repositoryAccessor.RemoveFromToManyRelationshipAsync(resourceFromDatabase, secondaryResourceIds, cancellationToken); + try + { + await _repositoryAccessor.SetRelationshipAsync(resourceFromDatabase, effectiveRightValue, cancellationToken); } + catch (DataStoreUpdateException) + { + await AssertRightResourcesExistAsync(effectiveRightValue, cancellationToken); + throw; + } + } - protected async Task GetPrimaryResourceByIdAsync(TId id, TopFieldSelection fieldSelection, CancellationToken cancellationToken) + /// + public virtual async Task DeleteAsync([DisallowNull] TId id, CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - TResource primaryResource = await TryGetPrimaryResourceByIdAsync(id, fieldSelection, cancellationToken); - AssertPrimaryResourceExists(primaryResource); + id + }); - return primaryResource; - } + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); - private async Task TryGetPrimaryResourceByIdAsync(TId id, TopFieldSelection fieldSelection, CancellationToken cancellationToken) + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Delete resource"); + + TResource? resourceFromDatabase = null; + + if (_request.PrimaryResourceType.IsPartOfTypeHierarchy()) { - QueryLayer primaryLayer = _queryLayerComposer.ComposeForGetById(id, _request.PrimaryResource, fieldSelection); + // The resource to delete may be stored as a derived type. We fetch it, so we'll know the stored type, which + // enables to invoke IResourceDefinition with TResource being the stored resource type. + resourceFromDatabase = await GetPrimaryResourceForUpdateAsync(id, cancellationToken); + AccurizeJsonApiRequest(resourceFromDatabase); + } - IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync(primaryLayer, cancellationToken); - return primaryResources.SingleOrDefault(); + try + { + await _repositoryAccessor.DeleteAsync(resourceFromDatabase, id, cancellationToken); } + catch (DataStoreUpdateException) + { + await GetPrimaryResourceByIdAsync(id, TopFieldSelection.OnlyIdAttribute, cancellationToken); + throw; + } + } - protected async Task GetPrimaryResourceForUpdateAsync(TId id, CancellationToken cancellationToken) + /// + public virtual async Task RemoveFromToManyRelationshipAsync([DisallowNull] TId leftId, string relationshipName, ISet rightResourceIds, + CancellationToken cancellationToken) + { + _traceWriter.LogMethodStart(new { - QueryLayer queryLayer = _queryLayerComposer.ComposeForUpdate(id, _request.PrimaryResource); - var resource = await _repositoryAccessor.GetForUpdateAsync(queryLayer, cancellationToken); + leftId, + relationshipName, + rightResourceIds + }); - AssertPrimaryResourceExists(resource); - return resource; - } + ArgumentNullException.ThrowIfNull(relationshipName); + ArgumentNullException.ThrowIfNull(rightResourceIds); + AssertHasRelationship(_request.Relationship, relationshipName); + + using IDisposable _ = CodeTimingSessionManager.Current.Measure("Repository - Remove from to-many relationship"); + + var hasManyRelationship = (HasManyAttribute)_request.Relationship; + + TResource resourceFromDatabase = await GetForHasManyUpdateAsync(hasManyRelationship, leftId, rightResourceIds, cancellationToken); + AccurizeJsonApiRequest(resourceFromDatabase); + + object? rightValue = await AssertRightResourcesExistAsync(rightResourceIds, cancellationToken); + ISet effectiveRightResourceIds = ((IEnumerable)rightValue!).ToHashSet(IdentifiableComparer.Instance); + + await _resourceDefinitionAccessor.OnPrepareWriteAsync(resourceFromDatabase, WriteOperationKind.SetRelationship, cancellationToken); + + await _repositoryAccessor.RemoveFromToManyRelationshipAsync(resourceFromDatabase, effectiveRightResourceIds, cancellationToken); + } + + protected async Task GetPrimaryResourceByIdAsync([DisallowNull] TId id, TopFieldSelection fieldSelection, CancellationToken cancellationToken) + { + TResource? primaryResource = await GetPrimaryResourceByIdOrDefaultAsync(id, fieldSelection, cancellationToken); + AssertPrimaryResourceExists(primaryResource); + + return primaryResource; + } + + private async Task GetPrimaryResourceByIdOrDefaultAsync([DisallowNull] TId id, TopFieldSelection fieldSelection, + CancellationToken cancellationToken) + { + // 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, resourceType, fieldSelection); + + IReadOnlyCollection primaryResources = await _repositoryAccessor.GetAsync(primaryLayer, cancellationToken); + return primaryResources.SingleOrDefault(); + } + + protected async Task GetPrimaryResourceForUpdateAsync([DisallowNull] TId id, CancellationToken cancellationToken) + { + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + + QueryLayer queryLayer = _queryLayerComposer.ComposeForUpdate(id, _request.PrimaryResourceType); + var resource = await _repositoryAccessor.GetForUpdateAsync(queryLayer, cancellationToken); + AssertPrimaryResourceExists(resource); + + return resource; + } + + private void AccurizeJsonApiRequest(TResource resourceFromDatabase) + { + // When using resource inheritance, the stored left-side resource may be more derived than what this endpoint assumes. + // In that case, we promote data in IJsonApiRequest to better represent what is going on. + + Type storedType = resourceFromDatabase.GetClrType(); - [AssertionMethod] - private void AssertPrimaryResourceExists(TResource resource) + if (storedType != typeof(TResource)) { - if (resource == null) + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + ResourceType? derivedType = _request.PrimaryResourceType.GetAllConcreteDerivedTypes().FirstOrDefault(type => type.ClrType == storedType); + + if (derivedType == null) { - throw new ResourceNotFoundException(_request.PrimaryId, _request.PrimaryResource.PublicName); + throw new InvalidConfigurationException($"Type '{storedType}' does not exist in the resource graph."); } - } - [AssertionMethod] - private void AssertHasRelationship(RelationshipAttribute relationship, string name) - { - if (relationship == null) + var request = (JsonApiRequest)_request; + request.PrimaryResourceType = derivedType; + + if (request.Relationship != null) { - throw new RelationshipNotFoundException(name, _request.PrimaryResource.PublicName); + request.Relationship = derivedType.GetRelationshipByPublicName(request.Relationship.PublicName); } } } - /// - /// Represents the foundational Resource Service layer in the JsonApiDotNetCore architecture that uses a Resource Repository for data access. - /// - /// - /// The resource type. - /// - [PublicAPI] - public class JsonApiResourceService : JsonApiResourceService, IResourceService - where TResource : class, IIdentifiable + [AssertionMethod] + private void AssertPrimaryResourceExists([SysNotNull] TResource? resource) + { + AssertPrimaryResourceTypeInJsonApiRequestIsNotNull(_request.PrimaryResourceType); + + if (resource == null) + { + throw new ResourceNotFoundException(_request.PrimaryId!, _request.PrimaryResourceType.PublicName); + } + } + + [AssertionMethod] + private void AssertHasRelationship([SysNotNull] RelationshipAttribute? relationship, string name) + { + if (relationship == null) + { + throw new RelationshipNotFoundException(name, _request.PrimaryResourceType!.PublicName); + } + } + + [AssertionMethod] + private void AssertPrimaryResourceTypeInJsonApiRequestIsNotNull([SysNotNull] ResourceType? resourceType) + { + if (resourceType == null) + { + throw new InvalidOperationException( + $"Expected {nameof(IJsonApiRequest)}.{nameof(IJsonApiRequest.PrimaryResourceType)} not to be null at this point."); + } + } + + [AssertionMethod] + private void AssertRelationshipInJsonApiRequestIsNotNull([SysNotNull] RelationshipAttribute? relationship) { - public JsonApiResourceService(IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, - IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, - IResourceChangeTracker resourceChangeTracker, IResourceHookExecutorFacade hookExecutor) - : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, hookExecutor) + if (relationship == null) { + throw new InvalidOperationException($"Expected {nameof(IJsonApiRequest)}.{nameof(IJsonApiRequest.Relationship)} not to be null at this point."); } } } diff --git a/src/JsonApiDotNetCore/TypeExtensions.cs b/src/JsonApiDotNetCore/TypeExtensions.cs deleted file mode 100644 index d25599b821..0000000000 --- a/src/JsonApiDotNetCore/TypeExtensions.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System; -using System.Linq; - -namespace JsonApiDotNetCore -{ - internal static class TypeExtensions - { - /// - /// Whether the specified source type implements or equals the specified interface. - /// - public static bool IsOrImplementsInterface(this Type source, Type interfaceType) - { - ArgumentGuard.NotNull(interfaceType, nameof(interfaceType)); - - if (source == null) - { - return false; - } - - return source == interfaceType || source.GetInterfaces().Any(type => type == interfaceType); - } - } -} 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 d9c503c784..11567b9113 100644 --- a/test/DiscoveryTests/DiscoveryTests.csproj +++ b/test/DiscoveryTests/DiscoveryTests.csproj @@ -1,13 +1,9 @@ - + - $(NetCoreAppVersion) + net9.0;net8.0 - - - PreserveNewest - - + @@ -16,7 +12,9 @@ + - + + - \ No newline at end of file + 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 new file mode 100644 index 0000000000..9ad2daef51 --- /dev/null +++ b/test/DiscoveryTests/PrivateResource.cs @@ -0,0 +1,9 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace DiscoveryTests; + +[UsedImplicitly(ImplicitUseTargetFlags.Members)] +[Resource] +public sealed class PrivateResource : Identifiable; diff --git a/test/DiscoveryTests/PrivateResourceDefinition.cs b/test/DiscoveryTests/PrivateResourceDefinition.cs new file mode 100644 index 0000000000..3003ce389c --- /dev/null +++ b/test/DiscoveryTests/PrivateResourceDefinition.cs @@ -0,0 +1,9 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Resources; + +namespace DiscoveryTests; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class PrivateResourceDefinition(IResourceGraph resourceGraph) + : JsonApiResourceDefinition(resourceGraph); diff --git a/test/DiscoveryTests/PrivateResourceRepository.cs b/test/DiscoveryTests/PrivateResourceRepository.cs new file mode 100644 index 0000000000..eb33d18440 --- /dev/null +++ b/test/DiscoveryTests/PrivateResourceRepository.cs @@ -0,0 +1,15 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using Microsoft.Extensions.Logging; + +namespace DiscoveryTests; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +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 new file mode 100644 index 0000000000..bad4b41428 --- /dev/null +++ b/test/DiscoveryTests/PrivateResourceService.cs @@ -0,0 +1,18 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Queries; +using JsonApiDotNetCore.Repositories; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace DiscoveryTests; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +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 a13c6340f5..eeca3cdb89 100644 --- a/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs +++ b/test/DiscoveryTests/ServiceDiscoveryFacadeTests.cs @@ -1,8 +1,5 @@ using FluentAssertions; using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Hooks; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Services; @@ -11,146 +8,122 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; -using Moq; using Xunit; -namespace DiscoveryTests +namespace DiscoveryTests; + +public sealed class ServiceDiscoveryFacadeTests { - public sealed class ServiceDiscoveryFacadeTests + private readonly ServiceCollection _services = []; + + public ServiceDiscoveryFacadeTests() { - private static readonly NullLoggerFactory LoggerFactory = NullLoggerFactory.Instance; - private readonly IServiceCollection _services = new ServiceCollection(); - private readonly JsonApiOptions _options = new JsonApiOptions(); - private readonly ResourceGraphBuilder _resourceGraphBuilder; + _services.AddSingleton(_ => NullLoggerFactory.Instance); + _services.AddScoped(_ => new FakeDbContextResolver()); + } - public ServiceDiscoveryFacadeTests() - { - var dbResolverMock = new Mock(); - dbResolverMock.Setup(resolver => resolver.GetContext()).Returns(new Mock().Object); - _services.AddScoped(_ => dbResolverMock.Object); - - _services.AddSingleton(_options); - _services.AddSingleton(LoggerFactory); - _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); - _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); - } + [Fact] + public void Can_add_resources_from_assembly_to_graph() + { + // Arrange + Action addAction = facade => facade.AddAssembly(typeof(Person).Assembly); - [Fact] - public void Can_add_resources_from_assembly_to_graph() - { - // Arrange - var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, LoggerFactory); - facade.AddAssembly(typeof(Person).Assembly); + // Act + _services.AddJsonApi(discovery: facade => addAction(facade)); - // Act - facade.DiscoverResources(); + // Assert + ServiceProvider serviceProvider = _services.BuildServiceProvider(); + var resourceGraph = serviceProvider.GetRequiredService(); - // Assert - IResourceGraph resourceGraph = _resourceGraphBuilder.Build(); + ResourceType? personType = resourceGraph.FindResourceType(typeof(Person)); + personType.Should().NotBeNull(); - ResourceContext personContext = resourceGraph.GetResourceContext(typeof(Person)); - personContext.Should().NotBeNull(); + ResourceType? todoItemType = resourceGraph.FindResourceType(typeof(TodoItem)); + todoItemType.Should().NotBeNull(); + } - ResourceContext todoItemContext = resourceGraph.GetResourceContext(typeof(TodoItem)); - todoItemContext.Should().NotBeNull(); - } + [Fact] + public void Can_add_resource_from_current_assembly_to_graph() + { + // Arrange + Action addAction = facade => facade.AddCurrentAssembly(); - [Fact] - public void Can_add_resource_from_current_assembly_to_graph() - { - // Arrange - var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, LoggerFactory); - facade.AddCurrentAssembly(); + // Act + _services.AddJsonApi(discovery: facade => addAction(facade)); - // Act - facade.DiscoverResources(); + // Assert + ServiceProvider serviceProvider = _services.BuildServiceProvider(); + var resourceGraph = serviceProvider.GetRequiredService(); - // Assert - IResourceGraph resourceGraph = _resourceGraphBuilder.Build(); + ResourceType? resourceType = resourceGraph.FindResourceType(typeof(PrivateResource)); + resourceType.Should().NotBeNull(); + } - ResourceContext testContext = resourceGraph.GetResourceContext(typeof(TestResource)); - testContext.Should().NotBeNull(); - } + [Fact] + public void Can_add_resource_service_from_current_assembly_to_container() + { + // Arrange + Action addAction = facade => facade.AddCurrentAssembly(); - [Fact] - public void Can_add_resource_service_from_current_assembly_to_container() - { - // Arrange - var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, LoggerFactory); - facade.AddCurrentAssembly(); + // Act + _services.AddJsonApi(discovery: facade => addAction(facade)); - // Act - facade.DiscoverInjectables(); + // Assert + ServiceProvider serviceProvider = _services.BuildServiceProvider(); + var resourceService = serviceProvider.GetRequiredService>(); - // Assert - ServiceProvider services = _services.BuildServiceProvider(); + resourceService.Should().BeOfType(); + } - var resourceService = services.GetRequiredService>(); - resourceService.Should().BeOfType(); - } + [Fact] + public void Can_add_resource_repository_from_current_assembly_to_container() + { + // Arrange + Action addAction = facade => facade.AddCurrentAssembly(); - [Fact] - public void Can_add_resource_repository_from_current_assembly_to_container() - { - // Arrange - var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, LoggerFactory); - facade.AddCurrentAssembly(); + // Act + _services.AddJsonApi(discovery: facade => addAction(facade)); - // Act - facade.DiscoverInjectables(); + // Assert + ServiceProvider serviceProvider = _services.BuildServiceProvider(); + var resourceRepository = serviceProvider.GetRequiredService>(); - // Assert - ServiceProvider services = _services.BuildServiceProvider(); + resourceRepository.Should().BeOfType(); + } - var resourceRepository = services.GetRequiredService>(); - resourceRepository.Should().BeOfType(); - } + [Fact] + public void Can_add_resource_definition_from_current_assembly_to_container() + { + // Arrange + Action addAction = facade => facade.AddCurrentAssembly(); - [Fact] - public void Can_add_resource_definition_from_current_assembly_to_container() - { - // Arrange - var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, LoggerFactory); - facade.AddCurrentAssembly(); + // Act + _services.AddJsonApi(discovery: facade => addAction(facade)); - // Act - facade.DiscoverInjectables(); + // Assert + ServiceProvider serviceProvider = _services.BuildServiceProvider(); + var resourceDefinition = serviceProvider.GetRequiredService>(); - // Assert - ServiceProvider services = _services.BuildServiceProvider(); + resourceDefinition.Should().BeOfType(); + } - var resourceDefinition = services.GetRequiredService>(); - resourceDefinition.Should().BeOfType(); - } + private sealed class FakeDbContextResolver : IDbContextResolver + { + private readonly FakeDbContextOptions _dbContextOptions = new(); - [Fact] - public void Can_add_resource_hooks_definition_from_current_assembly_to_container() + public DbContext GetContext() { - // Arrange - var facade = new ServiceDiscoveryFacade(_services, _resourceGraphBuilder, _options, LoggerFactory); - facade.AddCurrentAssembly(); - - _options.EnableResourceHooks = true; - - // Act - facade.DiscoverInjectables(); + return new DbContext(_dbContextOptions); + } - // Assert - ServiceProvider services = _services.BuildServiceProvider(); + private sealed class FakeDbContextOptions : DbContextOptions + { + public override Type ContextType => typeof(object); - var resourceHooksDefinition = services.GetRequiredService>(); - resourceHooksDefinition.Should().BeOfType(); + public override DbContextOptions WithExtension(TExtension extension) + { + return this; + } } } } diff --git a/test/DiscoveryTests/TestResource.cs b/test/DiscoveryTests/TestResource.cs deleted file mode 100644 index f394c920b0..0000000000 --- a/test/DiscoveryTests/TestResource.cs +++ /dev/null @@ -1,10 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; - -namespace DiscoveryTests -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class TestResource : Identifiable - { - } -} diff --git a/test/DiscoveryTests/TestResourceDefinition.cs b/test/DiscoveryTests/TestResourceDefinition.cs deleted file mode 100644 index f327916d4f..0000000000 --- a/test/DiscoveryTests/TestResourceDefinition.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; - -namespace DiscoveryTests -{ - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class TestResourceDefinition : JsonApiResourceDefinition - { - public TestResourceDefinition(IResourceGraph resourceGraph) - : base(resourceGraph) - { - } - } -} diff --git a/test/DiscoveryTests/TestResourceHooksDefinition.cs b/test/DiscoveryTests/TestResourceHooksDefinition.cs deleted file mode 100644 index 0bfc1bd03b..0000000000 --- a/test/DiscoveryTests/TestResourceHooksDefinition.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; - -namespace DiscoveryTests -{ - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class TestResourceHooksDefinition : ResourceHooksDefinition - { - public TestResourceHooksDefinition(IResourceGraph resourceGraph) - : base(resourceGraph) - { - } - } -} diff --git a/test/DiscoveryTests/TestResourceRepository.cs b/test/DiscoveryTests/TestResourceRepository.cs deleted file mode 100644 index b0c60f8b1b..0000000000 --- a/test/DiscoveryTests/TestResourceRepository.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Repositories; -using JsonApiDotNetCore.Resources; -using Microsoft.Extensions.Logging; - -namespace DiscoveryTests -{ - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class TestResourceRepository : EntityFrameworkCoreRepository - { - public TestResourceRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory) - { - } - } -} diff --git a/test/DiscoveryTests/TestResourceService.cs b/test/DiscoveryTests/TestResourceService.cs deleted file mode 100644 index d3955049cb..0000000000 --- a/test/DiscoveryTests/TestResourceService.cs +++ /dev/null @@ -1,23 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Hooks; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Repositories; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace DiscoveryTests -{ - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class TestResourceService : JsonApiResourceService - { - public TestResourceService(IResourceRepositoryAccessor repositoryAccessor, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, - IJsonApiOptions options, ILoggerFactory loggerFactory, IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, - IResourceHookExecutorFacade hookExecutor) - : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, hookExecutor) - { - } - } -} 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/JsonApiDotNetCoreExampleTests/ExampleIntegrationTestContext.cs b/test/JsonApiDotNetCoreExampleTests/ExampleIntegrationTestContext.cs deleted file mode 100644 index 96c92656db..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/ExampleIntegrationTestContext.cs +++ /dev/null @@ -1,23 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCoreExample.Startups; -using Microsoft.EntityFrameworkCore; -using TestBuildingBlocks; - -namespace JsonApiDotNetCoreExampleTests -{ - /// - /// A test context for tests that reference the JsonApiDotNetCoreExample project. - /// - /// - /// The server Startup class, which can be defined in the test project. - /// - /// - /// The EF Core database context, which can be defined in the test project. - /// - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class ExampleIntegrationTestContext : BaseIntegrationTestContext - where TStartup : class - where TDbContext : DbContext - { - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/ArchiveTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/ArchiveTests.cs deleted file mode 100644 index b7700f5ffd..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/ArchiveTests.cs +++ /dev/null @@ -1,665 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExampleTests.Startups; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving -{ - public sealed class ArchiveTests : IClassFixture, TelevisionDbContext>> - { - private readonly ExampleIntegrationTestContext, TelevisionDbContext> _testContext; - private readonly TelevisionFakers _fakers = new TelevisionFakers(); - - public ArchiveTests(ExampleIntegrationTestContext, TelevisionDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - testContext.UseController(); - testContext.UseController(); - testContext.UseController(); - - testContext.ConfigureServicesAfterStartup(services => - { - services.AddResourceDefinition(); - }); - } - - [Fact] - public async Task Can_get_archived_resource_by_ID() - { - // Arrange - TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Broadcasts.Add(broadcast); - await dbContext.SaveChangesAsync(); - }); - - string route = "/televisionBroadcasts/" + broadcast.StringId; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Id.Should().Be(broadcast.StringId); - responseDocument.SingleData.Attributes["archivedAt"].Should().BeCloseTo(broadcast.ArchivedAt); - } - - [Fact] - public async Task Can_get_unarchived_resource_by_ID() - { - // Arrange - TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); - broadcast.ArchivedAt = null; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Broadcasts.Add(broadcast); - await dbContext.SaveChangesAsync(); - }); - - string route = "/televisionBroadcasts/" + broadcast.StringId; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Id.Should().Be(broadcast.StringId); - responseDocument.SingleData.Attributes["archivedAt"].Should().BeNull(); - } - - [Fact] - public async Task Get_primary_resources_excludes_archived() - { - // Arrange - List broadcasts = _fakers.TelevisionBroadcast.Generate(2); - broadcasts[1].ArchivedAt = null; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Broadcasts.AddRange(broadcasts); - await dbContext.SaveChangesAsync(); - }); - - const string route = "/televisionBroadcasts"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Id.Should().Be(broadcasts[1].StringId); - responseDocument.ManyData[0].Attributes["archivedAt"].Should().BeNull(); - } - - [Fact] - public async Task Get_primary_resources_with_filter_includes_archived() - { - // Arrange - List broadcasts = _fakers.TelevisionBroadcast.Generate(2); - broadcasts[1].ArchivedAt = null; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Broadcasts.AddRange(broadcasts); - await dbContext.SaveChangesAsync(); - }); - - const string route = "/televisionBroadcasts?filter=or(equals(archivedAt,null),not(equals(archivedAt,null)))"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(2); - responseDocument.ManyData[0].Id.Should().Be(broadcasts[0].StringId); - responseDocument.ManyData[0].Attributes["archivedAt"].Should().BeCloseTo(broadcasts[0].ArchivedAt); - responseDocument.ManyData[1].Id.Should().Be(broadcasts[1].StringId); - responseDocument.ManyData[1].Attributes["archivedAt"].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(); - station.Broadcasts.ElementAt(1).ArchivedAt = null; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Stations.Add(station); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/televisionStations/{station.StringId}?include=broadcasts"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Id.Should().Be(station.StringId); - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); - responseDocument.Included[0].Attributes["archivedAt"].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(); - station.Broadcasts.ElementAt(1).ArchivedAt = null; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Stations.Add(station); - await dbContext.SaveChangesAsync(); - }); - - string route = - $"/televisionStations/{station.StringId}?include=broadcasts&filter[broadcasts]=or(equals(archivedAt,null),not(equals(archivedAt,null)))"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Id.Should().Be(station.StringId); - - responseDocument.Included.Should().HaveCount(2); - responseDocument.Included[0].Id.Should().Be(station.Broadcasts.ElementAt(0).StringId); - responseDocument.Included[0].Attributes["archivedAt"].Should().BeCloseTo(station.Broadcasts.ElementAt(0).ArchivedAt); - responseDocument.Included[1].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); - responseDocument.Included[1].Attributes["archivedAt"].Should().BeNull(); - } - - [Fact] - public async Task Get_secondary_resource_includes_archived() - { - // Arrange - BroadcastComment comment = _fakers.BroadcastComment.Generate(); - comment.AppliesTo = _fakers.TelevisionBroadcast.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Comments.Add(comment); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/broadcastComments/{comment.StringId}/appliesTo"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Id.Should().Be(comment.AppliesTo.StringId); - responseDocument.SingleData.Attributes["archivedAt"].Should().BeCloseTo(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(); - station.Broadcasts.ElementAt(1).ArchivedAt = null; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Stations.Add(station); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/televisionStations/{station.StringId}/broadcasts"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); - responseDocument.ManyData[0].Attributes["archivedAt"].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(); - station.Broadcasts.ElementAt(1).ArchivedAt = null; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Stations.Add(station); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/televisionStations/{station.StringId}/broadcasts?filter=or(equals(archivedAt,null),not(equals(archivedAt,null)))"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(2); - responseDocument.ManyData[0].Id.Should().Be(station.Broadcasts.ElementAt(0).StringId); - responseDocument.ManyData[0].Attributes["archivedAt"].Should().BeCloseTo(station.Broadcasts.ElementAt(0).ArchivedAt); - responseDocument.ManyData[1].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); - responseDocument.ManyData[1].Attributes["archivedAt"].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(); - network.Stations.ElementAt(0).Broadcasts.ElementAt(1).ArchivedAt = null; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Networks.Add(network); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/televisionNetworks/{network.StringId}/stations?include=broadcasts"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Id.Should().Be(network.Stations.ElementAt(0).StringId); - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Id.Should().Be(network.Stations.ElementAt(0).Broadcasts.ElementAt(1).StringId); - responseDocument.Included[0].Attributes["archivedAt"].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(); - network.Stations.ElementAt(0).Broadcasts.ElementAt(1).ArchivedAt = null; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Networks.Add(network); - await dbContext.SaveChangesAsync(); - }); - - string route = - $"/televisionNetworks/{network.StringId}/stations?include=broadcasts&filter[broadcasts]=or(equals(archivedAt,null),not(equals(archivedAt,null)))"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Id.Should().Be(network.Stations.ElementAt(0).StringId); - - responseDocument.Included.Should().HaveCount(2); - responseDocument.Included[0].Id.Should().Be(network.Stations.ElementAt(0).Broadcasts.ElementAt(0).StringId); - responseDocument.Included[0].Attributes["archivedAt"].Should().BeCloseTo(network.Stations.ElementAt(0).Broadcasts.ElementAt(0).ArchivedAt); - responseDocument.Included[1].Id.Should().Be(network.Stations.ElementAt(0).Broadcasts.ElementAt(1).StringId); - responseDocument.Included[1].Attributes["archivedAt"].Should().BeNull(); - } - - [Fact] - public async Task Get_ToMany_relationship_excludes_archived() - { - // Arrange - TelevisionStation station = _fakers.TelevisionStation.Generate(); - station.Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); - station.Broadcasts.ElementAt(1).ArchivedAt = null; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Stations.Add(station); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/televisionStations/{station.StringId}/relationships/broadcasts"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); - } - - [Fact] - public async Task Get_ToMany_relationship_with_filter_includes_archived() - { - // Arrange - TelevisionStation station = _fakers.TelevisionStation.Generate(); - station.Broadcasts = _fakers.TelevisionBroadcast.Generate(2).ToHashSet(); - station.Broadcasts.ElementAt(1).ArchivedAt = null; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Stations.Add(station); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/televisionStations/{station.StringId}/relationships/broadcasts?filter=or(equals(archivedAt,null),not(equals(archivedAt,null)))"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(2); - responseDocument.ManyData[0].Id.Should().Be(station.Broadcasts.ElementAt(0).StringId); - responseDocument.ManyData[1].Id.Should().Be(station.Broadcasts.ElementAt(1).StringId); - } - - [Fact] - public async Task Can_create_unarchived_resource() - { - // Arrange - TelevisionBroadcast newBroadcast = _fakers.TelevisionBroadcast.Generate(); - - var requestBody = new - { - data = new - { - type = "televisionBroadcasts", - attributes = new - { - title = newBroadcast.Title, - airedAt = newBroadcast.AiredAt - } - } - }; - - const string route = "/televisionBroadcasts"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["title"].Should().Be(newBroadcast.Title); - responseDocument.SingleData.Attributes["airedAt"].Should().BeCloseTo(newBroadcast.AiredAt); - responseDocument.SingleData.Attributes["archivedAt"].Should().BeNull(); - } - - [Fact] - public async Task Cannot_create_archived_resource() - { - // Arrange - TelevisionBroadcast newBroadcast = _fakers.TelevisionBroadcast.Generate(); - - var requestBody = new - { - data = new - { - type = "televisionBroadcasts", - attributes = new - { - title = newBroadcast.Title, - airedAt = newBroadcast.AiredAt, - archivedAt = newBroadcast.ArchivedAt - } - } - }; - - const string route = "/televisionBroadcasts"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Forbidden); - error.Title.Should().Be("Television broadcasts cannot be created in archived state."); - error.Detail.Should().BeNull(); - } - - [Fact] - public async Task Can_archive_resource() - { - // Arrange - TelevisionBroadcast existingBroadcast = _fakers.TelevisionBroadcast.Generate(); - existingBroadcast.ArchivedAt = null; - - DateTimeOffset newArchivedAt = _fakers.TelevisionBroadcast.Generate().ArchivedAt!.Value; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Broadcasts.Add(existingBroadcast); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "televisionBroadcasts", - id = existingBroadcast.StringId, - attributes = new - { - archivedAt = newArchivedAt - } - } - }; - - string route = "/televisionBroadcasts/" + existingBroadcast.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - TelevisionBroadcast broadcastInDatabase = await dbContext.Broadcasts.FirstWithIdAsync(existingBroadcast.Id); - - broadcastInDatabase.ArchivedAt.Should().BeCloseTo(newArchivedAt); - }); - } - - [Fact] - public async Task Can_unarchive_resource() - { - // Arrange - TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Broadcasts.Add(broadcast); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "televisionBroadcasts", - id = broadcast.StringId, - attributes = new - { - archivedAt = (DateTimeOffset?)null - } - } - }; - - string route = "/televisionBroadcasts/" + broadcast.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - TelevisionBroadcast broadcastInDatabase = await dbContext.Broadcasts.FirstWithIdAsync(broadcast.Id); - - broadcastInDatabase.ArchivedAt.Should().BeNull(); - }); - } - - [Fact] - public async Task Cannot_shift_archive_date() - { - // Arrange - TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); - - DateTimeOffset? newArchivedAt = _fakers.TelevisionBroadcast.Generate().ArchivedAt; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Broadcasts.Add(broadcast); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "televisionBroadcasts", - id = broadcast.StringId, - attributes = new - { - archivedAt = newArchivedAt - } - } - }; - - string route = "/televisionBroadcasts/" + broadcast.StringId; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Forbidden); - error.Title.Should().Be("Archive date of television broadcasts cannot be shifted. Unarchive it first."); - error.Detail.Should().BeNull(); - } - - [Fact] - public async Task Can_delete_archived_resource() - { - // Arrange - TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Broadcasts.Add(broadcast); - await dbContext.SaveChangesAsync(); - }); - - string route = "/televisionBroadcasts/" + broadcast.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - TelevisionBroadcast broadcastInDatabase = await dbContext.Broadcasts.FirstWithIdOrDefaultAsync(broadcast.Id); - - broadcastInDatabase.Should().BeNull(); - }); - } - - [Fact] - public async Task Cannot_delete_unarchived_resource() - { - // Arrange - TelevisionBroadcast broadcast = _fakers.TelevisionBroadcast.Generate(); - broadcast.ArchivedAt = null; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Broadcasts.Add(broadcast); - await dbContext.SaveChangesAsync(); - }); - - string route = "/televisionBroadcasts/" + broadcast.StringId; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteDeleteAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Forbidden); - error.Title.Should().Be("Television broadcasts must first be archived before they can be deleted."); - error.Detail.Should().BeNull(); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/BroadcastComment.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/BroadcastComment.cs deleted file mode 100644 index d0d244681e..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/BroadcastComment.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class BroadcastComment : Identifiable - { - [Attr] - public string Text { get; set; } - - [Attr] - public DateTimeOffset CreatedAt { get; set; } - - [HasOne] - public TelevisionBroadcast AppliesTo { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/BroadcastCommentsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/BroadcastCommentsController.cs deleted file mode 100644 index e6d5e3d1b6..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/BroadcastCommentsController.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving -{ - public sealed class BroadcastCommentsController : JsonApiController - { - public BroadcastCommentsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcast.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcast.cs deleted file mode 100644 index 97e3c3f4be..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcast.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class TelevisionBroadcast : Identifiable - { - [Attr] - public string Title { get; set; } - - [Attr] - public DateTimeOffset AiredAt { get; set; } - - [Attr] - public DateTimeOffset? ArchivedAt { get; set; } - - [HasOne] - public TelevisionStation AiredOn { get; set; } - - [HasMany] - public ISet Comments { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs deleted file mode 100644 index 191bca53dc..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcastDefinition.cs +++ /dev/null @@ -1,204 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Threading; -using System.Threading.Tasks; -using JetBrains.Annotations; -using JsonApiDotNetCore; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.EntityFrameworkCore; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving -{ - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class TelevisionBroadcastDefinition : JsonApiResourceDefinition - { - private readonly TelevisionDbContext _dbContext; - private readonly IJsonApiRequest _request; - private readonly IEnumerable _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) - { - // Rule: hide archived broadcasts in collections, unless a filter is specified. - - if (IsReturningCollectionOfTelevisionBroadcasts() && !HasFilterOnArchivedAt(existingFilter)) - { - AttrAttribute archivedAtAttribute = ResourceContext.Attributes.Single(attr => attr.Property.Name == nameof(TelevisionBroadcast.ArchivedAt)); - - var archivedAtChain = new ResourceFieldChainExpression(archivedAtAttribute); - - FilterExpression isUnarchived = new ComparisonExpression(ComparisonOperator.Equals, archivedAtChain, new NullConstantExpression()); - - return existingFilter == null - ? isUnarchived - : new LogicalExpression(LogicalOperator.And, ArrayFactory.Create(existingFilter, isUnarchived)); - } - } - - return base.OnApplyFilter(existingFilter); - } - - private bool IsReturningCollectionOfTelevisionBroadcasts() - { - return IsRequestingCollectionOfTelevisionBroadcasts() || IsIncludingCollectionOfTelevisionBroadcasts(); - } - - private bool IsRequestingCollectionOfTelevisionBroadcasts() - { - if (_request.IsCollection) - { - if (_request.PrimaryResource == ResourceContext || _request.SecondaryResource == ResourceContext) - { - return true; - } - } - - return false; - } - - private bool IsIncludingCollectionOfTelevisionBroadcasts() - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - IncludeElementExpression[] includeElements = _constraintProviders - .SelectMany(provider => provider.GetConstraints()) - .Select(expressionInScope => expressionInScope.Expression) - .OfType() - .SelectMany(include => include.Elements) - .ToArray(); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - foreach (IncludeElementExpression includeElement in includeElements) - { - if (includeElement.Relationship is HasManyAttribute && includeElement.Relationship.RightType == ResourceContext.ResourceType) - { - return true; - } - } - - return false; - } - - private bool HasFilterOnArchivedAt(FilterExpression existingFilter) - { - if (existingFilter == null) - { - return false; - } - - var walker = new FilterWalker(); - walker.Visit(existingFilter, null); - - return walker.HasFilterOnArchivedAt; - } - - public override Task OnPrepareWriteAsync(TelevisionBroadcast broadcast, OperationKind operationKind, CancellationToken cancellationToken) - { - if (operationKind == OperationKind.UpdateResource) - { - _storedArchivedAt = broadcast.ArchivedAt; - } - - return base.OnPrepareWriteAsync(broadcast, operationKind, cancellationToken); - } - - public override async Task OnWritingAsync(TelevisionBroadcast broadcast, OperationKind operationKind, CancellationToken cancellationToken) - { - if (operationKind == OperationKind.CreateResource) - { - AssertIsNotArchived(broadcast); - } - else if (operationKind == OperationKind.UpdateResource) - { - AssertIsNotShiftingArchiveDate(broadcast); - } - else if (operationKind == OperationKind.DeleteResource) - { - TelevisionBroadcast broadcastToDelete = - await _dbContext.Broadcasts.FirstOrDefaultAsync(resource => resource.Id == broadcast.Id, cancellationToken); - - if (broadcastToDelete != null) - { - AssertIsArchived(broadcastToDelete); - } - } - - await base.OnWritingAsync(broadcast, operationKind, cancellationToken); - } - - [AssertionMethod] - private static void AssertIsNotArchived(TelevisionBroadcast broadcast) - { - if (broadcast.ArchivedAt != null) - { - throw new JsonApiException(new Error(HttpStatusCode.Forbidden) - { - Title = "Television broadcasts cannot be created in archived state." - }); - } - } - - [AssertionMethod] - private void AssertIsNotShiftingArchiveDate(TelevisionBroadcast broadcast) - { - if (_storedArchivedAt != null && broadcast.ArchivedAt != null && _storedArchivedAt != broadcast.ArchivedAt) - { - throw new JsonApiException(new Error(HttpStatusCode.Forbidden) - { - Title = "Archive date of television broadcasts cannot be shifted. Unarchive it first." - }); - } - } - - [AssertionMethod] - private static void AssertIsArchived(TelevisionBroadcast broadcast) - { - if (broadcast.ArchivedAt == null) - { - throw new JsonApiException(new Error(HttpStatusCode.Forbidden) - { - Title = "Television broadcasts must first be archived before they can be deleted." - }); - } - } - - private sealed class FilterWalker : QueryExpressionRewriter - { - public bool HasFilterOnArchivedAt { get; private set; } - - public override QueryExpression VisitResourceFieldChain(ResourceFieldChainExpression expression, object argument) - { - if (expression.Fields.First().Property.Name == nameof(TelevisionBroadcast.ArchivedAt)) - { - HasFilterOnArchivedAt = true; - } - - return base.VisitResourceFieldChain(expression, argument); - } - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcastsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcastsController.cs deleted file mode 100644 index cc6575523c..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionBroadcastsController.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving -{ - public sealed class TelevisionBroadcastsController : JsonApiController - { - public TelevisionBroadcastsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionDbContext.cs deleted file mode 100644 index 8bc71dea0d..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionDbContext.cs +++ /dev/null @@ -1,19 +0,0 @@ -using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class TelevisionDbContext : DbContext - { - public DbSet Networks { get; set; } - public DbSet Stations { get; set; } - public DbSet Broadcasts { get; set; } - public DbSet Comments { get; set; } - - public TelevisionDbContext(DbContextOptions options) - : base(options) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionFakers.cs deleted file mode 100644 index 288c75bdbf..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionFakers.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using Bogus; -using TestBuildingBlocks; - -// @formatter:wrap_chained_method_calls chop_always -// @formatter:keep_existing_linebreaks true - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving -{ - internal sealed class TelevisionFakers : FakerContainer - { - private readonly Lazy> _lazyTelevisionNetworkFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(network => network.Name, faker => faker.Company.CompanyName())); - - private readonly Lazy> _lazyTelevisionStationFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(station => station.Name, faker => faker.Company.CompanyName())); - - private readonly Lazy> _lazyTelevisionBroadcastFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(broadcast => broadcast.Title, faker => faker.Lorem.Sentence()) - .RuleFor(broadcast => broadcast.AiredAt, faker => faker.Date.PastOffset()) - .RuleFor(broadcast => broadcast.ArchivedAt, faker => faker.Date.RecentOffset())); - - private readonly Lazy> _lazyBroadcastCommentFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(comment => comment.Text, faker => faker.Lorem.Paragraph()) - .RuleFor(comment => comment.CreatedAt, faker => faker.Date.PastOffset())); - - public Faker TelevisionNetwork => _lazyTelevisionNetworkFaker.Value; - public Faker TelevisionStation => _lazyTelevisionStationFaker.Value; - public Faker TelevisionBroadcast => _lazyTelevisionBroadcastFaker.Value; - public Faker BroadcastComment => _lazyBroadcastCommentFaker.Value; - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionNetwork.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionNetwork.cs deleted file mode 100644 index 3d5c213d31..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionNetwork.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class TelevisionNetwork : Identifiable - { - [Attr] - public string Name { get; set; } - - [HasMany] - public ISet Stations { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionNetworksController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionNetworksController.cs deleted file mode 100644 index f89eef1f92..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionNetworksController.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving -{ - public sealed class TelevisionNetworksController : JsonApiController - { - public TelevisionNetworksController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionStation.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionStation.cs deleted file mode 100644 index 8087ceff75..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionStation.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class TelevisionStation : Identifiable - { - [Attr] - public string Name { get; set; } - - [HasMany] - public ISet Broadcasts { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionStationsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionStationsController.cs deleted file mode 100644 index 490778b789..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Archiving/TelevisionStationsController.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Archiving -{ - public sealed class TelevisionStationsController : JsonApiController - { - public TelevisionStationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs deleted file mode 100644 index 243e07294d..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/AtomicConstrainedOperationsControllerTests.cs +++ /dev/null @@ -1,217 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExampleTests.Startups; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Controllers -{ - public sealed class AtomicConstrainedOperationsControllerTests - : IClassFixture, OperationsDbContext>> - { - private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new OperationsFakers(); - - public AtomicConstrainedOperationsControllerTests(ExampleIntegrationTestContext, 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, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(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, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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.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, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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.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.AddRange(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, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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.Pointer.Should().Be("/atomic:operations[0]"); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs deleted file mode 100644 index 21b4fd4ac5..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Controllers/CreateMusicTrackOperationsController.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System.Collections.Generic; -using System.Net; -using System.Threading; -using System.Threading.Tasks; -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; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Controllers -{ - [DisableRoutingConvention] - [Route("/operations/musicTracks/create")] - public sealed class CreateMusicTrackOperationsController : JsonApiOperationsController - { - public CreateMusicTrackOperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IOperationsProcessor processor, - IJsonApiRequest request, ITargetedFields targetedFields) - : base(options, loggerFactory, processor, request, targetedFields) - { - } - - public override async Task PostOperationsAsync(IList operations, CancellationToken cancellationToken) - { - AssertOnlyCreatingMusicTracks(operations); - - return await base.PostOperationsAsync(operations, cancellationToken); - } - - private static void AssertOnlyCreatingMusicTracks(IEnumerable operations) - { - int index = 0; - - foreach (OperationContainer operation in operations) - { - if (operation.Kind != OperationKind.CreateResource || operation.Resource.GetType() != typeof(MusicTrack)) - { - throw new JsonApiException(new Error(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 = - { - Pointer = $"/atomic:operations[{index}]" - } - }); - } - - index++; - } - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs deleted file mode 100644 index ae864c878a..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs +++ /dev/null @@ -1,820 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using FluentAssertions.Extensions; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Controllers; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.EntityFrameworkCore; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Creating -{ - public sealed class AtomicCreateResourceTests : IClassFixture, OperationsDbContext>> - { - private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new OperationsFakers(); - - public AtomicCreateResourceTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - - // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. - testContext.UseController(); - testContext.UseController(); - testContext.UseController(); - } - - [Fact] - public async Task Can_create_resource() - { - // Arrange - string newArtistName = _fakers.Performer.Generate().ArtistName; - DateTimeOffset newBornAt = _fakers.Performer.Generate().BornAt; - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "performers", - attributes = new - { - artistName = newArtistName, - bornAt = newBornAt - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].SingleData.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Type.Should().Be("performers"); - responseDocument.Results[0].SingleData.Attributes["artistName"].Should().Be(newArtistName); - responseDocument.Results[0].SingleData.Attributes["bornAt"].Should().BeCloseTo(newBornAt); - responseDocument.Results[0].SingleData.Relationships.Should().BeNull(); - - int newPerformerId = int.Parse(responseDocument.Results[0].SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Performer performerInDatabase = await dbContext.Performers.FirstWithIdAsync(newPerformerId); - - performerInDatabase.ArtistName.Should().Be(newArtistName); - performerInDatabase.BornAt.Should().BeCloseTo(newBornAt); - }); - } - - [Fact] - public async Task Can_create_resources() - { - // Arrange - const int elementCount = 5; - - List newTracks = _fakers.MusicTrack.Generate(elementCount); - - var operationElements = new List(elementCount); - - for (int index = 0; index < elementCount; index++) - { - operationElements.Add(new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTracks[index].Title, - lengthInSeconds = newTracks[index].LengthInSeconds, - genre = newTracks[index].Genre, - releasedAt = newTracks[index].ReleasedAt - } - } - }); - } - - var requestBody = new - { - atomic__operations = operationElements - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(elementCount); - - for (int index = 0; index < elementCount; index++) - { - ResourceObject singleData = responseDocument.Results[index].SingleData; - - singleData.Should().NotBeNull(); - singleData.Type.Should().Be("musicTracks"); - singleData.Attributes["title"].Should().Be(newTracks[index].Title); - singleData.Attributes["lengthInSeconds"].As().Should().BeApproximately(newTracks[index].LengthInSeconds); - singleData.Attributes["genre"].Should().Be(newTracks[index].Genre); - singleData.Attributes["releasedAt"].Should().BeCloseTo(newTracks[index].ReleasedAt); - singleData.Relationships.Should().NotBeEmpty(); - } - - Guid[] newTrackIds = responseDocument.Results.Select(result => Guid.Parse(result.SingleData.Id)).ToArray(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List tracksInDatabase = await dbContext.MusicTracks.Where(musicTrack => newTrackIds.Contains(musicTrack.Id)).ToListAsync(); - - tracksInDatabase.Should().HaveCount(elementCount); - - for (int index = 0; index < elementCount; index++) - { - MusicTrack trackInDatabase = tracksInDatabase.Single(musicTrack => musicTrack.Id == newTrackIds[index]); - - trackInDatabase.Title.Should().Be(newTracks[index].Title); - trackInDatabase.LengthInSeconds.Should().BeApproximately(newTracks[index].LengthInSeconds); - trackInDatabase.Genre.Should().Be(newTracks[index].Genre); - trackInDatabase.ReleasedAt.Should().BeCloseTo(newTracks[index].ReleasedAt); - } - }); - } - - [Fact] - public async Task Can_create_resource_without_attributes_or_relationships() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "performers", - attributes = new - { - }, - relationship = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].SingleData.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Type.Should().Be("performers"); - responseDocument.Results[0].SingleData.Attributes["artistName"].Should().BeNull(); - responseDocument.Results[0].SingleData.Attributes["bornAt"].Should().BeCloseTo(default(DateTimeOffset)); - responseDocument.Results[0].SingleData.Relationships.Should().BeNull(); - - int newPerformerId = int.Parse(responseDocument.Results[0].SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Performer performerInDatabase = await dbContext.Performers.FirstWithIdAsync(newPerformerId); - - performerInDatabase.ArtistName.Should().BeNull(); - performerInDatabase.BornAt.Should().Be(default); - }); - } - - [Fact] - public async Task Can_create_resource_with_unknown_attribute() - { - // Arrange - string newName = _fakers.Playlist.Generate().Name; - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "playlists", - attributes = new - { - doesNotExist = "ignored", - name = newName - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].SingleData.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Type.Should().Be("playlists"); - responseDocument.Results[0].SingleData.Attributes["name"].Should().Be(newName); - responseDocument.Results[0].SingleData.Relationships.Should().NotBeEmpty(); - - long newPlaylistId = long.Parse(responseDocument.Results[0].SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist performerInDatabase = await dbContext.Playlists.FirstWithIdAsync(newPlaylistId); - - performerInDatabase.Name.Should().Be(newName); - }); - } - - [Fact] - public async Task Can_create_resource_with_unknown_relationship() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "lyrics", - relationships = new - { - doesNotExist = new - { - data = new - { - type = "doesNotExist", - id = 12345678 - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].SingleData.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Type.Should().Be("lyrics"); - responseDocument.Results[0].SingleData.Attributes.Should().NotBeEmpty(); - responseDocument.Results[0].SingleData.Relationships.Should().NotBeEmpty(); - - long newLyricId = long.Parse(responseDocument.Results[0].SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Lyric lyricInDatabase = await dbContext.Lyrics.FirstWithIdAsync(newLyricId); - - lyricInDatabase.Should().NotBeNull(); - }); - } - - [Fact] - public async Task Cannot_create_resource_with_client_generated_ID() - { - // Arrange - string newTitle = _fakers.MusicTrack.Generate().Title; - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - id = Guid.NewGuid().ToString(), - attributes = new - { - title = newTitle - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Forbidden); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.Forbidden); - error.Title.Should().Be("Specifying the resource ID in operations that create a resource is not allowed."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]/data/id"); - } - - [Fact] - public async Task Cannot_create_resource_for_href_element() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - href = "/api/v1/musicTracks" - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Usage of the 'href' element is not supported."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_resource_for_ref_element() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "musicTracks" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.relationship' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_resource_for_missing_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add" - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_resource_for_missing_type() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - attributes = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data.type' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_resource_for_unknown_type() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "doesNotExist" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); - error.Detail.Should().Be("Resource type 'doesNotExist' does not exist."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_resource_for_array() - { - // Arrange - string newArtistName = _fakers.Performer.Generate().ArtistName; - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new[] - { - new - { - type = "performers", - attributes = new - { - artistName = newArtistName - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected single data element for create/update resource operation."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [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, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Setting the initial value of the requested attribute is not allowed."); - error.Detail.Should().Be("Setting the initial value of 'createdAt' is not allowed."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_resource_with_readonly_attribute() - { - // Arrange - string newPlaylistName = _fakers.Playlist.Generate().Name; - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "playlists", - attributes = new - { - name = newPlaylistName, - isArchived = true - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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' is read-only."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_resource_with_incompatible_attribute_value() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "performers", - attributes = new - { - bornAt = "not-a-valid-time" - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); - error.Detail.Should().StartWith("Failed to convert 'not-a-valid-time' of type 'String' to type 'DateTimeOffset'. - Request body:"); - error.Source.Pointer.Should().BeNull(); - } - - [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(); - - string newTitle = _fakers.MusicTrack.Generate().Title; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingLyric, existingCompany, existingPerformer); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTitle - }, - 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, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].SingleData.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Type.Should().Be("musicTracks"); - responseDocument.Results[0].SingleData.Attributes["title"].Should().Be(newTitle); - responseDocument.Results[0].SingleData.Relationships.Should().NotBeEmpty(); - - Guid newTrackId = Guid.Parse(responseDocument.Results[0].SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - MusicTrack trackInDatabase = await dbContext.MusicTracks - .Include(musicTrack => musicTrack.Lyric) - .Include(musicTrack => musicTrack.OwnedBy) - .Include(musicTrack => musicTrack.Performers) - .FirstWithIdAsync(newTrackId); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - trackInDatabase.Title.Should().Be(newTitle); - - trackInDatabase.Lyric.Should().NotBeNull(); - trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); - - trackInDatabase.OwnedBy.Should().NotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); - - trackInDatabase.Performers.Should().HaveCount(1); - trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); - }); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs deleted file mode 100644 index 3e40fa3094..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs +++ /dev/null @@ -1,267 +0,0 @@ -using System; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Controllers; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Creating -{ - public sealed class AtomicCreateResourceWithClientGeneratedIdTests - : IClassFixture, OperationsDbContext>> - { - private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new OperationsFakers(); - - public AtomicCreateResourceWithClientGeneratedIdTests( - ExampleIntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - - // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. - testContext.UseController(); - - 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() - { - // Arrange - TextLanguage newLanguage = _fakers.TextLanguage.Generate(); - newLanguage.Id = Guid.NewGuid(); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "textLanguages", - id = newLanguage.StringId, - attributes = new - { - isoCode = newLanguage.IsoCode - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].SingleData.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Type.Should().Be("textLanguages"); - responseDocument.Results[0].SingleData.Attributes["isoCode"].Should().Be(newLanguage.IsoCode); - responseDocument.Results[0].SingleData.Attributes.Should().NotContainKey("concurrencyToken"); - responseDocument.Results[0].SingleData.Relationships.Should().NotBeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - TextLanguage languageInDatabase = await dbContext.TextLanguages.FirstWithIdAsync(newLanguage.Id); - - languageInDatabase.IsoCode.Should().Be(newLanguage.IsoCode); - }); - } - - [Fact] - public async Task Can_create_resource_with_client_generated_string_ID_having_no_side_effects() - { - // Arrange - MusicTrack newTrack = _fakers.MusicTrack.Generate(); - newTrack.Id = Guid.NewGuid(); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - id = newTrack.StringId, - attributes = new - { - title = newTrack.Title, - lengthInSeconds = newTrack.LengthInSeconds - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.FirstWithIdAsync(newTrack.Id); - - trackInDatabase.Title.Should().Be(newTrack.Title); - trackInDatabase.LengthInSeconds.Should().BeApproximately(newTrack.LengthInSeconds); - }); - } - - [Fact] - public async Task Cannot_create_resource_for_existing_client_generated_ID() - { - // Arrange - TextLanguage existingLanguage = _fakers.TextLanguage.Generate(); - existingLanguage.Id = Guid.NewGuid(); - - TextLanguage languageToCreate = _fakers.TextLanguage.Generate(); - languageToCreate.Id = existingLanguage.Id; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TextLanguages.Add(languageToCreate); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "textLanguages", - id = languageToCreate.StringId, - attributes = new - { - isoCode = languageToCreate.IsoCode - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Conflict); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_resource_for_incompatible_ID() - { - // Arrange - string guid = Guid.NewGuid().ToString(); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "performers", - id = guid, - attributes = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); - error.Detail.Should().Be($"Failed to convert '{guid}' of type 'String' to type 'Int32'."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_resource_for_ID_and_local_ID() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "lyrics", - id = 12345678, - lid = "local-1" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data.id' or 'data.lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs deleted file mode 100644 index 3f910178ae..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs +++ /dev/null @@ -1,633 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Controllers; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.EntityFrameworkCore; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Creating -{ - public sealed class AtomicCreateResourceWithToManyRelationshipTests - : IClassFixture, OperationsDbContext>> - { - private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new OperationsFakers(); - - public AtomicCreateResourceWithToManyRelationshipTests( - ExampleIntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - - // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. - testContext.UseController(); - testContext.UseController(); - } - - [Fact] - public async Task Can_create_HasMany_relationship() - { - // Arrange - List existingPerformers = _fakers.Performer.Generate(2); - string newTitle = _fakers.MusicTrack.Generate().Title; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Performers.AddRange(existingPerformers); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTitle - }, - relationships = new - { - performers = new - { - data = new[] - { - new - { - type = "performers", - id = existingPerformers[0].StringId - }, - new - { - type = "performers", - id = existingPerformers[1].StringId - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].SingleData.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Type.Should().Be("musicTracks"); - responseDocument.Results[0].SingleData.Attributes.Should().NotBeEmpty(); - responseDocument.Results[0].SingleData.Relationships.Should().NotBeEmpty(); - - Guid newTrackId = Guid.Parse(responseDocument.Results[0].SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(newTrackId); - - 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); - }); - } - - [Fact] - public async Task Can_create_HasManyThrough_relationship() - { - // Arrange - List existingTracks = _fakers.MusicTrack.Generate(3); - string newName = _fakers.Playlist.Generate().Name; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.AddRange(existingTracks); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "playlists", - attributes = new - { - name = newName - }, - relationships = new - { - tracks = new - { - data = new[] - { - new - { - type = "musicTracks", - id = existingTracks[0].StringId - }, - new - { - type = "musicTracks", - id = existingTracks[1].StringId - }, - new - { - type = "musicTracks", - id = existingTracks[2].StringId - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].SingleData.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Type.Should().Be("playlists"); - responseDocument.Results[0].SingleData.Attributes.Should().NotBeEmpty(); - responseDocument.Results[0].SingleData.Relationships.Should().NotBeEmpty(); - - long newPlaylistId = long.Parse(responseDocument.Results[0].SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - Playlist playlistInDatabase = await dbContext.Playlists - .Include(playlist => playlist.PlaylistMusicTracks) - .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) - .FirstWithIdAsync(newPlaylistId); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(3); - playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[0].Id); - playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[1].Id); - playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[2].Id); - }); - } - - [Fact] - public async Task Cannot_create_for_missing_relationship_type() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - relationships = new - { - performers = new - { - data = new[] - { - new - { - id = 12345678 - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); - error.Detail.Should().Be("Expected 'type' element in 'performers' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_for_unknown_relationship_type() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - relationships = new - { - performers = new - { - data = new[] - { - new - { - type = "doesNotExist", - id = 12345678 - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); - error.Detail.Should().Be("Resource type 'doesNotExist' does not exist."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_for_missing_relationship_ID() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - relationships = new - { - performers = new - { - data = new[] - { - new - { - type = "performers" - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body must include 'id' or 'lid' element."); - error.Detail.Should().Be("Expected 'id' or 'lid' element in 'performers' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_for_unknown_relationship_IDs() - { - // Arrange - string newTitle = _fakers.MusicTrack.Generate().Title; - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTitle - }, - relationships = new - { - performers = new - { - data = new[] - { - new - { - type = "performers", - id = 12345678 - }, - new - { - type = "performers", - id = 87654321 - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(2); - - Error 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 '12345678' in relationship 'performers' does not exist."); - error1.Source.Pointer.Should().Be("/atomic:operations[0]"); - - Error 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 'performers' with ID '87654321' in relationship 'performers' does not exist."); - error2.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_on_relationship_type_mismatch() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - relationships = new - { - performers = new - { - data = new[] - { - new - { - type = "playlists", - id = 12345678 - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Relationship contains incompatible resource type."); - error.Detail.Should().Be("Relationship 'performers' contains incompatible resource type 'playlists'."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Can_create_with_duplicates() - { - // Arrange - Performer existingPerformer = _fakers.Performer.Generate(); - string newTitle = _fakers.MusicTrack.Generate().Title; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Performers.Add(existingPerformer); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTitle - }, - relationships = new - { - performers = new - { - data = new[] - { - new - { - type = "performers", - id = existingPerformer.StringId - }, - new - { - type = "performers", - id = existingPerformer.StringId - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].SingleData.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Type.Should().Be("musicTracks"); - responseDocument.Results[0].SingleData.Attributes.Should().NotBeEmpty(); - responseDocument.Results[0].SingleData.Relationships.Should().NotBeEmpty(); - - Guid newTrackId = Guid.Parse(responseDocument.Results[0].SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(newTrackId); - - trackInDatabase.Performers.Should().HaveCount(1); - trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); - }); - } - - [Fact] - public async Task Cannot_create_with_null_data_in_HasMany_relationship() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - relationships = new - { - performers = new - { - data = (object)null - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected data[] element for to-many relationship."); - error.Detail.Should().Be("Expected data[] element for 'performers' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_with_null_data_in_HasManyThrough_relationship() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "playlists", - relationships = new - { - tracks = new - { - data = (object)null - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected data[] element for to-many relationship."); - error.Detail.Should().Be("Expected data[] element for 'tracks' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs deleted file mode 100644 index 659f2abf28..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs +++ /dev/null @@ -1,627 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Controllers; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.EntityFrameworkCore; -using Newtonsoft.Json; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Creating -{ - public sealed class AtomicCreateResourceWithToOneRelationshipTests - : IClassFixture, OperationsDbContext>> - { - private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new OperationsFakers(); - - public AtomicCreateResourceWithToOneRelationshipTests( - ExampleIntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - - // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. - testContext.UseController(); - testContext.UseController(); - } - - [Fact] - public async Task Can_create_OneToOne_relationship_from_principal_side() - { - // 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 = "add", - data = new - { - type = "lyrics", - relationships = new - { - track = new - { - data = new - { - type = "musicTracks", - id = existingTrack.StringId - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].SingleData.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Type.Should().Be("lyrics"); - responseDocument.Results[0].SingleData.Attributes.Should().NotBeEmpty(); - responseDocument.Results[0].SingleData.Relationships.Should().NotBeEmpty(); - - long newLyricId = long.Parse(responseDocument.Results[0].SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(newLyricId); - - lyricInDatabase.Track.Should().NotBeNull(); - lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); - }); - } - - [Fact] - public async Task Can_create_OneToOne_relationship_from_dependent_side() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.Generate(); - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Lyrics.Add(existingLyric); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTrackTitle - }, - relationships = new - { - lyric = new - { - data = new - { - type = "lyrics", - id = existingLyric.StringId - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].SingleData.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Type.Should().Be("musicTracks"); - responseDocument.Results[0].SingleData.Attributes.Should().NotBeEmpty(); - responseDocument.Results[0].SingleData.Relationships.Should().NotBeEmpty(); - - Guid newTrackId = Guid.Parse(responseDocument.Results[0].SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(newTrackId); - - trackInDatabase.Lyric.Should().NotBeNull(); - trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); - }); - } - - [Fact] - 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(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.RecordCompanies.Add(existingCompany); - await dbContext.SaveChangesAsync(); - }); - - var operationElements = new List(elementCount); - - for (int index = 0; index < elementCount; index++) - { - operationElements.Add(new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTrackTitles[index] - }, - relationships = new - { - ownedBy = new - { - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } - } - } - } - }); - } - - var requestBody = new - { - atomic__operations = operationElements - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(elementCount); - - for (int index = 0; index < elementCount; index++) - { - responseDocument.Results[index].SingleData.Should().NotBeNull(); - responseDocument.Results[index].SingleData.Type.Should().Be("musicTracks"); - responseDocument.Results[index].SingleData.Attributes["title"].Should().Be(newTrackTitles[index]); - } - - Guid[] newTrackIds = responseDocument.Results.Select(result => Guid.Parse(result.SingleData.Id)).ToArray(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - List tracksInDatabase = await dbContext.MusicTracks - .Include(musicTrack => musicTrack.OwnedBy) - .Where(musicTrack => newTrackIds.Contains(musicTrack.Id)) - .ToListAsync(); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - tracksInDatabase.Should().HaveCount(elementCount); - - for (int index = 0; index < elementCount; index++) - { - MusicTrack trackInDatabase = tracksInDatabase.Single(musicTrack => musicTrack.Id == newTrackIds[index]); - - trackInDatabase.Title.Should().Be(newTrackTitles[index]); - - trackInDatabase.OwnedBy.Should().NotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); - } - }); - } - - [Fact] - public async Task Cannot_create_for_missing_relationship_type() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - relationships = new - { - lyric = new - { - data = new - { - id = 12345678 - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); - error.Detail.Should().Be("Expected 'type' element in 'lyric' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_for_unknown_relationship_type() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - relationships = new - { - lyric = new - { - data = new - { - type = "doesNotExist", - id = 12345678 - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); - error.Detail.Should().Be("Resource type 'doesNotExist' does not exist."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_for_missing_relationship_ID() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - relationships = new - { - lyric = new - { - data = new - { - type = "lyrics" - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body must include 'id' or 'lid' element."); - error.Detail.Should().Be("Expected 'id' or 'lid' element in 'lyric' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_with_unknown_relationship_ID() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - relationships = new - { - lyric = new - { - data = new - { - type = "lyrics", - id = 12345678 - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 '12345678' in relationship 'lyric' does not exist."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_on_relationship_type_mismatch() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - relationships = new - { - lyric = new - { - data = new - { - type = "playlists", - id = 12345678 - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Relationship contains incompatible resource type."); - error.Detail.Should().Be("Relationship 'lyric' contains incompatible resource type 'playlists'."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Can_create_resource_with_duplicate_relationship() - { - // Arrange - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.RecordCompanies.Add(existingCompany); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTrackTitle - }, - relationships = new - { - ownedBy = new - { - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } - }, - ownedBy_duplicate = new - { - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } - } - } - } - } - } - }; - - string requestBodyText = JsonConvert.SerializeObject(requestBody).Replace("ownedBy_duplicate", "ownedBy"); - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBodyText); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].SingleData.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Type.Should().Be("musicTracks"); - responseDocument.Results[0].SingleData.Attributes.Should().NotBeEmpty(); - responseDocument.Results[0].SingleData.Relationships.Should().NotBeEmpty(); - - Guid newTrackId = Guid.Parse(responseDocument.Results[0].SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(newTrackId); - - trackInDatabase.OwnedBy.Should().NotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); - }); - } - - [Fact] - public async Task Cannot_create_with_data_array_in_relationship() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - relationships = new - { - lyric = new - { - data = new[] - { - new - { - type = "lyrics", - id = 12345678 - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected single data element for to-one relationship."); - error.Detail.Should().Be("Expected single data element for 'lyric' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs deleted file mode 100644 index 360a39de53..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Deleting/AtomicDeleteResourceTests.cs +++ /dev/null @@ -1,625 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Controllers; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.EntityFrameworkCore; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Deleting -{ - public sealed class AtomicDeleteResourceTests : IClassFixture, OperationsDbContext>> - { - private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new OperationsFakers(); - - public AtomicDeleteResourceTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task Can_delete_existing_resource() - { - // Arrange - Performer existingPerformer = _fakers.Performer.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Performers.Add(existingPerformer); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "performers", - id = existingPerformer.StringId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Performer performerInDatabase = await dbContext.Performers.FirstWithIdOrDefaultAsync(existingPerformer.Id); - - performerInDatabase.Should().BeNull(); - }); - } - - [Fact] - public async Task Can_delete_existing_resources() - { - // Arrange - const int elementCount = 5; - - List existingTracks = _fakers.MusicTrack.Generate(elementCount); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.AddRange(existingTracks); - await dbContext.SaveChangesAsync(); - }); - - var operationElements = new List(elementCount); - - for (int index = 0; index < elementCount; index++) - { - operationElements.Add(new - { - op = "remove", - @ref = new - { - type = "musicTracks", - id = existingTracks[index].StringId - } - }); - } - - var requestBody = new - { - atomic__operations = operationElements - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - - tracksInDatabase.Should().BeEmpty(); - }); - } - - [Fact] - public async Task Can_delete_resource_with_OneToOne_relationship_from_principal_side() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.Generate(); - existingLyric.Track = _fakers.MusicTrack.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Lyrics.Add(existingLyric); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "lyrics", - id = existingLyric.StringId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Lyric lyricsInDatabase = await dbContext.Lyrics.FirstWithIdOrDefaultAsync(existingLyric.Id); - - lyricsInDatabase.Should().BeNull(); - - MusicTrack trackInDatabase = await dbContext.MusicTracks.FirstWithIdAsync(existingLyric.Track.Id); - - trackInDatabase.Lyric.Should().BeNull(); - }); - } - - [Fact] - public async Task Can_delete_resource_with_OneToOne_relationship_from_dependent_side() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Lyric = _fakers.Lyric.Generate(); - - 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 - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack tracksInDatabase = await dbContext.MusicTracks.FirstWithIdOrDefaultAsync(existingTrack.Id); - - tracksInDatabase.Should().BeNull(); - - Lyric lyricInDatabase = await dbContext.Lyrics.FirstWithIdAsync(existingTrack.Lyric.Id); - - lyricInDatabase.Track.Should().BeNull(); - }); - } - - [Fact] - public async Task Can_delete_existing_resource_with_HasMany_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Performers = _fakers.Performer.Generate(2); - - 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 - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.FirstWithIdOrDefaultAsync(existingTrack.Id); - - trackInDatabase.Should().BeNull(); - - List performersInDatabase = await dbContext.Performers.ToListAsync(); - - performersInDatabase.Should().ContainSingle(userAccount => userAccount.Id == existingTrack.Performers.ElementAt(0).Id); - performersInDatabase.Should().ContainSingle(userAccount => userAccount.Id == existingTrack.Performers.ElementAt(1).Id); - }); - } - - [Fact] - public async Task Can_delete_existing_resource_with_HasManyThrough_relationship() - { - // Arrange - var existingPlaylistMusicTrack = new PlaylistMusicTrack - { - Playlist = _fakers.Playlist.Generate(), - MusicTrack = _fakers.MusicTrack.Generate() - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.PlaylistMusicTracks.Add(existingPlaylistMusicTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "playlists", - id = existingPlaylistMusicTrack.Playlist.StringId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Playlist playlistInDatabase = await dbContext.Playlists.FirstWithIdOrDefaultAsync(existingPlaylistMusicTrack.Playlist.Id); - - playlistInDatabase.Should().BeNull(); - - PlaylistMusicTrack playlistTracksInDatabase = await dbContext.PlaylistMusicTracks.FirstOrDefaultAsync(playlistMusicTrack => - playlistMusicTrack.Playlist.Id == existingPlaylistMusicTrack.Playlist.Id); - - playlistTracksInDatabase.Should().BeNull(); - }); - } - - [Fact] - public async Task Cannot_delete_resource_for_href_element() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - href = "/api/v1/musicTracks/1" - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Usage of the 'href' element is not supported."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_delete_resource_for_missing_ref_element() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove" - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_delete_resource_for_missing_type() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - id = 99999999 - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.type' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_delete_resource_for_unknown_type() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "doesNotExist", - id = 99999999 - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); - error.Detail.Should().Be("Resource type 'doesNotExist' does not exist."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_delete_resource_for_missing_ID() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "musicTracks" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_delete_resource_for_unknown_ID() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "performers", - id = 99999999 - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 '99999999' does not exist."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_delete_resource_for_incompatible_ID() - { - // Arrange - string guid = Guid.NewGuid().ToString(); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "playlists", - id = guid - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); - error.Detail.Should().Be($"Failed to convert '{guid}' of type 'String' to type 'Int64'."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_delete_resource_for_ID_and_local_ID() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - id = Guid.NewGuid().ToString(), - lid = "local-1" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs deleted file mode 100644 index d8dc20a58d..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs +++ /dev/null @@ -1,164 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Controllers; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Links -{ - public sealed class AtomicAbsoluteLinksTests : IClassFixture, OperationsDbContext>> - { - private const string HostPrefix = "http://localhost"; - - private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new OperationsFakers(); - - public AtomicAbsoluteLinksTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - - // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. - testContext.UseController(); - testContext.UseController(); - - testContext.ConfigureServicesAfterStartup(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(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingLanguage, existingCompany); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "update", - data = new - { - type = "textLanguages", - id = existingLanguage.StringId, - attributes = new - { - } - } - }, - new - { - op = "update", - data = new - { - type = "recordCompanies", - id = existingCompany.StringId, - attributes = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(2); - - string languageLink = HostPrefix + "/textLanguages/" + existingLanguage.StringId; - - ResourceObject singleData1 = responseDocument.Results[0].SingleData; - singleData1.Should().NotBeNull(); - singleData1.Links.Should().NotBeNull(); - singleData1.Links.Self.Should().Be(languageLink); - singleData1.Relationships.Should().NotBeEmpty(); - singleData1.Relationships["lyrics"].Links.Should().NotBeNull(); - singleData1.Relationships["lyrics"].Links.Self.Should().Be(languageLink + "/relationships/lyrics"); - singleData1.Relationships["lyrics"].Links.Related.Should().Be(languageLink + "/lyrics"); - - string companyLink = HostPrefix + "/recordCompanies/" + existingCompany.StringId; - - ResourceObject singleData2 = responseDocument.Results[1].SingleData; - singleData2.Should().NotBeNull(); - singleData2.Links.Should().NotBeNull(); - singleData2.Links.Self.Should().Be(companyLink); - singleData2.Relationships.Should().NotBeEmpty(); - singleData2.Relationships["tracks"].Links.Should().NotBeNull(); - singleData2.Relationships["tracks"].Links.Self.Should().Be(companyLink + "/relationships/tracks"); - singleData2.Relationships["tracks"].Links.Related.Should().Be(companyLink + "/tracks"); - } - - [Fact] - public async Task Update_resource_with_side_effects_and_missing_resource_controller_hides_links() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Playlists.Add(existingPlaylist); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "update", - data = new - { - type = "playlists", - id = existingPlaylist.StringId, - attributes = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(1); - - ResourceObject singleData = responseDocument.Results[0].SingleData; - singleData.Should().NotBeNull(); - singleData.Links.Should().BeNull(); - singleData.Relationships.Should().BeNull(); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs deleted file mode 100644 index 6cd3b802c4..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs +++ /dev/null @@ -1,105 +0,0 @@ -using System; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Controllers; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Links -{ - public sealed class AtomicRelativeLinksWithNamespaceTests - : IClassFixture, OperationsDbContext>> - { - private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; - - public AtomicRelativeLinksWithNamespaceTests( - ExampleIntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - - // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. - testContext.UseController(); - testContext.UseController(); - - testContext.ConfigureServicesAfterStartup(services => - { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); - } - - [Fact] - public async Task Create_resource_with_side_effects_returns_relative_links() - { - // Arrange - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "textLanguages", - attributes = new - { - } - } - }, - new - { - op = "add", - data = new - { - type = "recordCompanies", - attributes = new - { - } - } - } - } - }; - - const string route = "/api/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(2); - - responseDocument.Results[0].SingleData.Should().NotBeNull(); - - string languageLink = "/api/textLanguages/" + Guid.Parse(responseDocument.Results[0].SingleData.Id); - - responseDocument.Results[0].SingleData.Links.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Links.Self.Should().Be(languageLink); - responseDocument.Results[0].SingleData.Relationships.Should().NotBeEmpty(); - responseDocument.Results[0].SingleData.Relationships["lyrics"].Links.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Relationships["lyrics"].Links.Self.Should().Be(languageLink + "/relationships/lyrics"); - responseDocument.Results[0].SingleData.Relationships["lyrics"].Links.Related.Should().Be(languageLink + "/lyrics"); - - responseDocument.Results[1].SingleData.Should().NotBeNull(); - - string companyLink = "/api/recordCompanies/" + short.Parse(responseDocument.Results[1].SingleData.Id); - - responseDocument.Results[1].SingleData.Links.Should().NotBeNull(); - responseDocument.Results[1].SingleData.Links.Self.Should().Be(companyLink); - responseDocument.Results[1].SingleData.Relationships.Should().NotBeEmpty(); - responseDocument.Results[1].SingleData.Relationships["tracks"].Links.Should().NotBeNull(); - responseDocument.Results[1].SingleData.Relationships["tracks"].Links.Self.Should().Be(companyLink + "/relationships/tracks"); - responseDocument.Results[1].SingleData.Relationships["tracks"].Links.Related.Should().Be(companyLink + "/tracks"); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs deleted file mode 100644 index 0481b4c253..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/LocalIds/AtomicLocalIdTests.cs +++ /dev/null @@ -1,2513 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Controllers; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.EntityFrameworkCore; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.LocalIds -{ - public sealed class AtomicLocalIdTests : IClassFixture, OperationsDbContext>> - { - private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new OperationsFakers(); - - public AtomicLocalIdTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task Can_create_resource_with_ToOne_relationship_using_local_ID() - { - // Arrange - RecordCompany newCompany = _fakers.RecordCompany.Generate(); - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - - const string companyLocalId = "company-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "recordCompanies", - lid = companyLocalId, - attributes = new - { - name = newCompany.Name, - countryOfResidence = newCompany.CountryOfResidence - } - } - }, - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTrackTitle - }, - relationships = new - { - ownedBy = new - { - data = new - { - type = "recordCompanies", - lid = companyLocalId - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(2); - - responseDocument.Results[0].SingleData.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Type.Should().Be("recordCompanies"); - responseDocument.Results[0].SingleData.Lid.Should().BeNull(); - responseDocument.Results[0].SingleData.Attributes["name"].Should().Be(newCompany.Name); - responseDocument.Results[0].SingleData.Attributes["countryOfResidence"].Should().Be(newCompany.CountryOfResidence); - - responseDocument.Results[1].SingleData.Should().NotBeNull(); - responseDocument.Results[1].SingleData.Type.Should().Be("musicTracks"); - responseDocument.Results[1].SingleData.Lid.Should().BeNull(); - responseDocument.Results[1].SingleData.Attributes["title"].Should().Be(newTrackTitle); - - short newCompanyId = short.Parse(responseDocument.Results[0].SingleData.Id); - Guid newTrackId = Guid.Parse(responseDocument.Results[1].SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(newTrackId); - - trackInDatabase.Title.Should().Be(newTrackTitle); - - trackInDatabase.OwnedBy.Should().NotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(newCompanyId); - trackInDatabase.OwnedBy.Name.Should().Be(newCompany.Name); - trackInDatabase.OwnedBy.CountryOfResidence.Should().Be(newCompany.CountryOfResidence); - }); - } - - [Fact] - public async Task Can_create_resource_with_OneToMany_relationship_using_local_ID() - { - // Arrange - Performer newPerformer = _fakers.Performer.Generate(); - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - - const string performerLocalId = "performer-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "performers", - lid = performerLocalId, - attributes = new - { - artistName = newPerformer.ArtistName, - bornAt = newPerformer.BornAt - } - } - }, - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTrackTitle - }, - relationships = new - { - performers = new - { - data = new[] - { - new - { - type = "performers", - lid = performerLocalId - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(2); - - responseDocument.Results[0].SingleData.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Type.Should().Be("performers"); - responseDocument.Results[0].SingleData.Lid.Should().BeNull(); - responseDocument.Results[0].SingleData.Attributes["artistName"].Should().Be(newPerformer.ArtistName); - responseDocument.Results[0].SingleData.Attributes["bornAt"].Should().BeCloseTo(newPerformer.BornAt); - - responseDocument.Results[1].SingleData.Should().NotBeNull(); - responseDocument.Results[1].SingleData.Type.Should().Be("musicTracks"); - responseDocument.Results[1].SingleData.Lid.Should().BeNull(); - responseDocument.Results[1].SingleData.Attributes["title"].Should().Be(newTrackTitle); - - int newPerformerId = int.Parse(responseDocument.Results[0].SingleData.Id); - Guid newTrackId = Guid.Parse(responseDocument.Results[1].SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(newTrackId); - - trackInDatabase.Title.Should().Be(newTrackTitle); - - 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().BeCloseTo(newPerformer.BornAt); - }); - } - - [Fact] - 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; - - const string trackLocalId = "track-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - } - } - }, - new - { - op = "add", - data = new - { - type = "playlists", - attributes = new - { - name = newPlaylistName - }, - relationships = new - { - tracks = new - { - data = new[] - { - new - { - type = "musicTracks", - lid = trackLocalId - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(2); - - responseDocument.Results[0].SingleData.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Type.Should().Be("musicTracks"); - responseDocument.Results[0].SingleData.Lid.Should().BeNull(); - responseDocument.Results[0].SingleData.Attributes["title"].Should().Be(newTrackTitle); - - responseDocument.Results[1].SingleData.Should().NotBeNull(); - responseDocument.Results[1].SingleData.Type.Should().Be("playlists"); - responseDocument.Results[1].SingleData.Lid.Should().BeNull(); - responseDocument.Results[1].SingleData.Attributes["name"].Should().Be(newPlaylistName); - - Guid newTrackId = Guid.Parse(responseDocument.Results[0].SingleData.Id); - long newPlaylistId = long.Parse(responseDocument.Results[1].SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - Playlist playlistInDatabase = await dbContext.Playlists - .Include(playlist => playlist.PlaylistMusicTracks) - .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) - .FirstWithIdAsync(newPlaylistId); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - playlistInDatabase.Name.Should().Be(newPlaylistName); - - playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(1); - playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Id.Should().Be(newTrackId); - playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Title.Should().Be(newTrackTitle); - }); - } - - [Fact] - public async Task Cannot_consume_local_ID_that_is_assigned_in_same_operation() - { - // Arrange - const string companyLocalId = "company-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "remove", - @ref = new - { - type = "lyrics", - id = 99999999 - } - }, - new - { - op = "add", - data = new - { - type = "recordCompanies", - lid = companyLocalId, - relationships = new - { - parent = new - { - data = new - { - type = "recordCompanies", - lid = companyLocalId - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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.Pointer.Should().Be("/atomic:operations[1]"); - } - - [Fact] - public async Task Cannot_reassign_local_ID() - { - // Arrange - string newPlaylistName = _fakers.Playlist.Generate().Name; - const string playlistLocalId = "playlist-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "remove", - @ref = new - { - type = "lyrics", - id = 99999999 - } - }, - new - { - op = "add", - data = new - { - type = "playlists", - lid = playlistLocalId, - attributes = new - { - name = newPlaylistName - } - } - }, - new - { - op = "add", - data = new - { - type = "playlists", - lid = playlistLocalId, - attributes = new - { - name = newPlaylistName - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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.Pointer.Should().Be("/atomic:operations[2]"); - } - - [Fact] - public async Task Can_update_resource_using_local_ID() - { - // Arrange - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newTrackGenre = _fakers.MusicTrack.Generate().Genre; - - const string trackLocalId = "track-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - } - } - }, - new - { - op = "update", - data = new - { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - genre = newTrackGenre - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(2); - - responseDocument.Results[0].SingleData.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Type.Should().Be("musicTracks"); - responseDocument.Results[0].SingleData.Lid.Should().BeNull(); - responseDocument.Results[0].SingleData.Attributes["title"].Should().Be(newTrackTitle); - responseDocument.Results[0].SingleData.Attributes["genre"].Should().BeNull(); - - responseDocument.Results[1].Data.Should().BeNull(); - - Guid newTrackId = Guid.Parse(responseDocument.Results[0].SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.FirstWithIdAsync(newTrackId); - - trackInDatabase.Title.Should().Be(newTrackTitle); - trackInDatabase.Genre.Should().Be(newTrackGenre); - }); - } - - [Fact] - 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; - - const string trackLocalId = "track-1"; - const string performerLocalId = "performer-1"; - const string companyLocalId = "company-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - } - } - }, - new - { - op = "add", - data = new - { - type = "performers", - lid = performerLocalId, - attributes = new - { - artistName = newArtistName - } - } - }, - new - { - op = "add", - data = new - { - type = "recordCompanies", - lid = companyLocalId, - attributes = new - { - name = newCompanyName - } - } - }, - new - { - op = "update", - data = new - { - type = "musicTracks", - lid = trackLocalId, - relationships = new - { - ownedBy = new - { - data = new - { - type = "recordCompanies", - lid = companyLocalId - } - }, - performers = new - { - data = new[] - { - new - { - type = "performers", - lid = performerLocalId - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(4); - - responseDocument.Results[0].SingleData.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Type.Should().Be("musicTracks"); - responseDocument.Results[0].SingleData.Lid.Should().BeNull(); - responseDocument.Results[0].SingleData.Attributes["title"].Should().Be(newTrackTitle); - - responseDocument.Results[1].SingleData.Should().NotBeNull(); - responseDocument.Results[1].SingleData.Type.Should().Be("performers"); - responseDocument.Results[1].SingleData.Lid.Should().BeNull(); - responseDocument.Results[1].SingleData.Attributes["artistName"].Should().Be(newArtistName); - - responseDocument.Results[2].SingleData.Should().NotBeNull(); - responseDocument.Results[2].SingleData.Type.Should().Be("recordCompanies"); - responseDocument.Results[2].SingleData.Lid.Should().BeNull(); - responseDocument.Results[2].SingleData.Attributes["name"].Should().Be(newCompanyName); - - responseDocument.Results[3].Data.Should().BeNull(); - - Guid newTrackId = Guid.Parse(responseDocument.Results[0].SingleData.Id); - int newPerformerId = int.Parse(responseDocument.Results[1].SingleData.Id); - short newCompanyId = short.Parse(responseDocument.Results[2].SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - MusicTrack trackInDatabase = await dbContext.MusicTracks - .Include(musicTrack => musicTrack.OwnedBy) - .Include(musicTrack => musicTrack.Performers) - .FirstWithIdAsync(newTrackId); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - trackInDatabase.Title.Should().Be(newTrackTitle); - - trackInDatabase.OwnedBy.Should().NotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(newCompanyId); - - trackInDatabase.Performers.Should().HaveCount(1); - trackInDatabase.Performers[0].Id.Should().Be(newPerformerId); - trackInDatabase.Performers[0].ArtistName.Should().Be(newArtistName); - }); - } - - [Fact] - public async Task Can_create_ToOne_relationship_using_local_ID() - { - // Arrange - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newCompanyName = _fakers.RecordCompany.Generate().Name; - - const string trackLocalId = "track-1"; - const string companyLocalId = "company-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - } - } - }, - new - { - op = "add", - data = new - { - type = "recordCompanies", - lid = companyLocalId, - attributes = new - { - name = newCompanyName - } - } - }, - new - { - op = "update", - @ref = new - { - type = "musicTracks", - lid = trackLocalId, - relationship = "ownedBy" - }, - data = new - { - type = "recordCompanies", - lid = companyLocalId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(3); - - responseDocument.Results[0].SingleData.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Type.Should().Be("musicTracks"); - responseDocument.Results[0].SingleData.Lid.Should().BeNull(); - responseDocument.Results[0].SingleData.Attributes["title"].Should().Be(newTrackTitle); - - responseDocument.Results[1].SingleData.Should().NotBeNull(); - responseDocument.Results[1].SingleData.Type.Should().Be("recordCompanies"); - responseDocument.Results[1].SingleData.Lid.Should().BeNull(); - responseDocument.Results[1].SingleData.Attributes["name"].Should().Be(newCompanyName); - - responseDocument.Results[2].Data.Should().BeNull(); - - Guid newTrackId = Guid.Parse(responseDocument.Results[0].SingleData.Id); - short newCompanyId = short.Parse(responseDocument.Results[1].SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(newTrackId); - - trackInDatabase.Title.Should().Be(newTrackTitle); - - trackInDatabase.OwnedBy.Should().NotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(newCompanyId); - trackInDatabase.OwnedBy.Name.Should().Be(newCompanyName); - }); - } - - [Fact] - public async Task Can_create_OneToMany_relationship_using_local_ID() - { - // Arrange - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newArtistName = _fakers.Performer.Generate().ArtistName; - - const string trackLocalId = "track-1"; - const string performerLocalId = "performer-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - } - } - }, - new - { - op = "add", - data = new - { - type = "performers", - lid = performerLocalId, - attributes = new - { - artistName = newArtistName - } - } - }, - new - { - op = "update", - @ref = new - { - type = "musicTracks", - lid = trackLocalId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - lid = performerLocalId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(3); - - responseDocument.Results[0].SingleData.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Type.Should().Be("musicTracks"); - responseDocument.Results[0].SingleData.Lid.Should().BeNull(); - responseDocument.Results[0].SingleData.Attributes["title"].Should().Be(newTrackTitle); - - responseDocument.Results[1].SingleData.Should().NotBeNull(); - responseDocument.Results[1].SingleData.Type.Should().Be("performers"); - responseDocument.Results[1].SingleData.Lid.Should().BeNull(); - responseDocument.Results[1].SingleData.Attributes["artistName"].Should().Be(newArtistName); - - responseDocument.Results[2].Data.Should().BeNull(); - - Guid newTrackId = Guid.Parse(responseDocument.Results[0].SingleData.Id); - int newPerformerId = int.Parse(responseDocument.Results[1].SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(newTrackId); - - trackInDatabase.Title.Should().Be(newTrackTitle); - - trackInDatabase.Performers.Should().HaveCount(1); - trackInDatabase.Performers[0].Id.Should().Be(newPerformerId); - trackInDatabase.Performers[0].ArtistName.Should().Be(newArtistName); - }); - } - - [Fact] - public async Task Can_create_ManyToMany_relationship_using_local_ID() - { - // Arrange - string newPlaylistName = _fakers.Playlist.Generate().Name; - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - - const string playlistLocalId = "playlist-1"; - const string trackLocalId = "track-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "playlists", - lid = playlistLocalId, - attributes = new - { - name = newPlaylistName - } - } - }, - new - { - op = "add", - data = new - { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - } - } - }, - new - { - op = "update", - @ref = new - { - type = "playlists", - lid = playlistLocalId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - lid = trackLocalId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(3); - - responseDocument.Results[0].SingleData.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Type.Should().Be("playlists"); - responseDocument.Results[0].SingleData.Lid.Should().BeNull(); - responseDocument.Results[0].SingleData.Attributes["name"].Should().Be(newPlaylistName); - - responseDocument.Results[1].SingleData.Should().NotBeNull(); - responseDocument.Results[1].SingleData.Type.Should().Be("musicTracks"); - responseDocument.Results[1].SingleData.Lid.Should().BeNull(); - responseDocument.Results[1].SingleData.Attributes["title"].Should().Be(newTrackTitle); - - responseDocument.Results[2].Data.Should().BeNull(); - - long newPlaylistId = long.Parse(responseDocument.Results[0].SingleData.Id); - Guid newTrackId = Guid.Parse(responseDocument.Results[1].SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - Playlist playlistInDatabase = await dbContext.Playlists - .Include(playlist => playlist.PlaylistMusicTracks) - .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) - .FirstWithIdAsync(newPlaylistId); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - playlistInDatabase.Name.Should().Be(newPlaylistName); - - playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(1); - playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Id.Should().Be(newTrackId); - playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Title.Should().Be(newTrackTitle); - }); - } - - [Fact] - public async Task Can_replace_OneToMany_relationship_using_local_ID() - { - // Arrange - Performer existingPerformer = _fakers.Performer.Generate(); - - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newArtistName = _fakers.Performer.Generate().ArtistName; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Performers.Add(existingPerformer); - await dbContext.SaveChangesAsync(); - }); - - const string trackLocalId = "track-1"; - const string performerLocalId = "performer-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - }, - relationships = new - { - performers = new - { - data = new[] - { - new - { - type = "performers", - id = existingPerformer.StringId - } - } - } - } - } - }, - new - { - op = "add", - data = new - { - type = "performers", - lid = performerLocalId, - attributes = new - { - artistName = newArtistName - } - } - }, - new - { - op = "update", - @ref = new - { - type = "musicTracks", - lid = trackLocalId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - lid = performerLocalId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(3); - - responseDocument.Results[0].SingleData.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Type.Should().Be("musicTracks"); - responseDocument.Results[0].SingleData.Lid.Should().BeNull(); - responseDocument.Results[0].SingleData.Attributes["title"].Should().Be(newTrackTitle); - - responseDocument.Results[1].SingleData.Should().NotBeNull(); - responseDocument.Results[1].SingleData.Type.Should().Be("performers"); - responseDocument.Results[1].SingleData.Lid.Should().BeNull(); - responseDocument.Results[1].SingleData.Attributes["artistName"].Should().Be(newArtistName); - - responseDocument.Results[2].Data.Should().BeNull(); - - Guid newTrackId = Guid.Parse(responseDocument.Results[0].SingleData.Id); - int newPerformerId = int.Parse(responseDocument.Results[1].SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(newTrackId); - - trackInDatabase.Title.Should().Be(newTrackTitle); - - trackInDatabase.Performers.Should().HaveCount(1); - trackInDatabase.Performers[0].Id.Should().Be(newPerformerId); - trackInDatabase.Performers[0].ArtistName.Should().Be(newArtistName); - }); - } - - [Fact] - public async Task Can_replace_ManyToMany_relationship_using_local_ID() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - - string newPlaylistName = _fakers.Playlist.Generate().Name; - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - const string playlistLocalId = "playlist-1"; - const string trackLocalId = "track-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "playlists", - lid = playlistLocalId, - attributes = new - { - name = newPlaylistName - }, - relationships = new - { - tracks = new - { - data = new[] - { - new - { - type = "musicTracks", - id = existingTrack.StringId - } - } - } - } - } - }, - new - { - op = "add", - data = new - { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - } - } - }, - new - { - op = "update", - @ref = new - { - type = "playlists", - lid = playlistLocalId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - lid = trackLocalId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(3); - - responseDocument.Results[0].SingleData.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Type.Should().Be("playlists"); - responseDocument.Results[0].SingleData.Lid.Should().BeNull(); - responseDocument.Results[0].SingleData.Attributes["name"].Should().Be(newPlaylistName); - - responseDocument.Results[1].SingleData.Should().NotBeNull(); - responseDocument.Results[1].SingleData.Type.Should().Be("musicTracks"); - responseDocument.Results[1].SingleData.Lid.Should().BeNull(); - responseDocument.Results[1].SingleData.Attributes["title"].Should().Be(newTrackTitle); - - responseDocument.Results[2].Data.Should().BeNull(); - - long newPlaylistId = long.Parse(responseDocument.Results[0].SingleData.Id); - Guid newTrackId = Guid.Parse(responseDocument.Results[1].SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - Playlist playlistInDatabase = await dbContext.Playlists - .Include(playlist => playlist.PlaylistMusicTracks) - .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) - .FirstWithIdAsync(newPlaylistId); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - playlistInDatabase.Name.Should().Be(newPlaylistName); - - playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(1); - playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Id.Should().Be(newTrackId); - playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Title.Should().Be(newTrackTitle); - }); - } - - [Fact] - public async Task Can_add_to_OneToMany_relationship_using_local_ID() - { - // Arrange - Performer existingPerformer = _fakers.Performer.Generate(); - - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newArtistName = _fakers.Performer.Generate().ArtistName; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Performers.Add(existingPerformer); - await dbContext.SaveChangesAsync(); - }); - - const string trackLocalId = "track-1"; - const string performerLocalId = "performer-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - }, - relationships = new - { - performers = new - { - data = new[] - { - new - { - type = "performers", - id = existingPerformer.StringId - } - } - } - } - } - }, - new - { - op = "add", - data = new - { - type = "performers", - lid = performerLocalId, - attributes = new - { - artistName = newArtistName - } - } - }, - new - { - op = "add", - @ref = new - { - type = "musicTracks", - lid = trackLocalId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - lid = performerLocalId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(3); - - responseDocument.Results[0].SingleData.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Type.Should().Be("musicTracks"); - responseDocument.Results[0].SingleData.Lid.Should().BeNull(); - responseDocument.Results[0].SingleData.Attributes["title"].Should().Be(newTrackTitle); - - responseDocument.Results[1].SingleData.Should().NotBeNull(); - responseDocument.Results[1].SingleData.Type.Should().Be("performers"); - responseDocument.Results[1].SingleData.Lid.Should().BeNull(); - responseDocument.Results[1].SingleData.Attributes["artistName"].Should().Be(newArtistName); - - responseDocument.Results[2].Data.Should().BeNull(); - - Guid newTrackId = Guid.Parse(responseDocument.Results[0].SingleData.Id); - int newPerformerId = int.Parse(responseDocument.Results[1].SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(newTrackId); - - trackInDatabase.Title.Should().Be(newTrackTitle); - - trackInDatabase.Performers.Should().HaveCount(2); - - trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); - trackInDatabase.Performers[0].ArtistName.Should().Be(existingPerformer.ArtistName); - - trackInDatabase.Performers[1].Id.Should().Be(newPerformerId); - trackInDatabase.Performers[1].ArtistName.Should().Be(newArtistName); - }); - } - - [Fact] - public async Task Can_add_to_ManyToMany_relationship_using_local_ID() - { - // Arrange - List existingTracks = _fakers.MusicTrack.Generate(2); - - string newPlaylistName = _fakers.Playlist.Generate().Name; - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - - const string playlistLocalId = "playlist-1"; - const string trackLocalId = "track-1"; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.AddRange(existingTracks); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "playlists", - lid = playlistLocalId, - attributes = new - { - name = newPlaylistName - }, - relationships = new - { - tracks = new - { - data = new[] - { - new - { - type = "musicTracks", - id = existingTracks[0].StringId - } - } - } - } - } - }, - new - { - op = "add", - data = new - { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - } - } - }, - new - { - op = "add", - @ref = new - { - type = "playlists", - lid = playlistLocalId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - lid = trackLocalId - } - } - }, - new - { - op = "add", - @ref = new - { - type = "playlists", - lid = playlistLocalId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = existingTracks[1].StringId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(4); - - responseDocument.Results[0].SingleData.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Type.Should().Be("playlists"); - responseDocument.Results[0].SingleData.Lid.Should().BeNull(); - responseDocument.Results[0].SingleData.Attributes["name"].Should().Be(newPlaylistName); - - responseDocument.Results[1].SingleData.Should().NotBeNull(); - responseDocument.Results[1].SingleData.Type.Should().Be("musicTracks"); - responseDocument.Results[1].SingleData.Lid.Should().BeNull(); - responseDocument.Results[1].SingleData.Attributes["title"].Should().Be(newTrackTitle); - - responseDocument.Results[2].Data.Should().BeNull(); - - responseDocument.Results[3].Data.Should().BeNull(); - - long newPlaylistId = long.Parse(responseDocument.Results[0].SingleData.Id); - Guid newTrackId = Guid.Parse(responseDocument.Results[1].SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - Playlist playlistInDatabase = await dbContext.Playlists - .Include(playlist => playlist.PlaylistMusicTracks) - .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) - .FirstWithIdAsync(newPlaylistId); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - playlistInDatabase.Name.Should().Be(newPlaylistName); - - playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(3); - playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[0].Id); - playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[1].Id); - playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == newTrackId); - }); - } - - [Fact] - public async Task Can_remove_from_OneToMany_relationship_using_local_ID() - { - // Arrange - Performer existingPerformer = _fakers.Performer.Generate(); - - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - string newArtistName1 = _fakers.Performer.Generate().ArtistName; - string newArtistName2 = _fakers.Performer.Generate().ArtistName; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Performers.Add(existingPerformer); - await dbContext.SaveChangesAsync(); - }); - - const string trackLocalId = "track-1"; - const string performerLocalId1 = "performer-1"; - const string performerLocalId2 = "performer-2"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "performers", - lid = performerLocalId1, - attributes = new - { - artistName = newArtistName1 - } - } - }, - new - { - op = "add", - data = new - { - type = "performers", - lid = performerLocalId2, - attributes = new - { - artistName = newArtistName2 - } - } - }, - new - { - op = "add", - data = new - { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - }, - relationships = new - { - performers = new - { - data = new object[] - { - new - { - type = "performers", - id = existingPerformer.StringId - }, - new - { - type = "performers", - lid = performerLocalId1 - }, - new - { - type = "performers", - lid = performerLocalId2 - } - } - } - } - } - }, - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - lid = trackLocalId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - lid = performerLocalId1 - }, - new - { - type = "performers", - lid = performerLocalId2 - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(4); - - responseDocument.Results[0].SingleData.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Type.Should().Be("performers"); - responseDocument.Results[0].SingleData.Lid.Should().BeNull(); - responseDocument.Results[0].SingleData.Attributes["artistName"].Should().Be(newArtistName1); - - responseDocument.Results[1].SingleData.Should().NotBeNull(); - responseDocument.Results[1].SingleData.Type.Should().Be("performers"); - responseDocument.Results[1].SingleData.Lid.Should().BeNull(); - responseDocument.Results[1].SingleData.Attributes["artistName"].Should().Be(newArtistName2); - - responseDocument.Results[2].SingleData.Should().NotBeNull(); - responseDocument.Results[2].SingleData.Type.Should().Be("musicTracks"); - responseDocument.Results[2].SingleData.Lid.Should().BeNull(); - responseDocument.Results[2].SingleData.Attributes["title"].Should().Be(newTrackTitle); - - responseDocument.Results[3].Data.Should().BeNull(); - - Guid newTrackId = Guid.Parse(responseDocument.Results[2].SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(newTrackId); - - trackInDatabase.Title.Should().Be(newTrackTitle); - - trackInDatabase.Performers.Should().HaveCount(1); - trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); - trackInDatabase.Performers[0].ArtistName.Should().Be(existingPerformer.ArtistName); - }); - } - - [Fact] - public async Task Can_remove_from_ManyToMany_relationship_using_local_ID() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); - - existingPlaylist.PlaylistMusicTracks = new[] - { - new PlaylistMusicTrack - { - MusicTrack = _fakers.MusicTrack.Generate() - }, - new PlaylistMusicTrack - { - MusicTrack = _fakers.MusicTrack.Generate() - } - }; - - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - - const string trackLocalId = "track-1"; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Playlists.Add(existingPlaylist); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - } - } - }, - new - { - op = "add", - @ref = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - lid = trackLocalId - } - } - }, - new - { - op = "remove", - @ref = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = existingPlaylist.PlaylistMusicTracks[1].MusicTrack.StringId - } - } - }, - new - { - op = "remove", - @ref = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - lid = trackLocalId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(4); - - responseDocument.Results[0].SingleData.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Type.Should().Be("musicTracks"); - responseDocument.Results[0].SingleData.Lid.Should().BeNull(); - responseDocument.Results[0].SingleData.Attributes["title"].Should().Be(newTrackTitle); - - responseDocument.Results[1].Data.Should().BeNull(); - - responseDocument.Results[2].Data.Should().BeNull(); - - responseDocument.Results[3].Data.Should().BeNull(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - Playlist playlistInDatabase = await dbContext.Playlists - .Include(playlist => playlist.PlaylistMusicTracks) - .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) - .FirstWithIdAsync(existingPlaylist.Id); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(1); - playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Id.Should().Be(existingPlaylist.PlaylistMusicTracks[0].MusicTrack.Id); - }); - } - - [Fact] - public async Task Can_delete_resource_using_local_ID() - { - // Arrange - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - - const string trackLocalId = "track-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - lid = trackLocalId, - attributes = new - { - title = newTrackTitle - } - } - }, - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - lid = trackLocalId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(2); - - responseDocument.Results[0].SingleData.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Type.Should().Be("musicTracks"); - responseDocument.Results[0].SingleData.Lid.Should().BeNull(); - responseDocument.Results[0].SingleData.Attributes["title"].Should().Be(newTrackTitle); - - responseDocument.Results[1].Data.Should().BeNull(); - - Guid newTrackId = Guid.Parse(responseDocument.Results[0].SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.FirstWithIdOrDefaultAsync(newTrackId); - - trackInDatabase.Should().BeNull(); - }); - } - - [Fact] - public async Task Cannot_consume_unassigned_local_ID_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "remove", - @ref = new - { - type = "lyrics", - id = 99999999 - } - }, - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - lid = "doesNotExist" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 'doesNotExist' is not available at this point."); - error.Source.Pointer.Should().Be("/atomic:operations[1]"); - } - - [Fact] - public async Task Cannot_consume_unassigned_local_ID_in_data_element() - { - // Arrange - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "remove", - @ref = new - { - type = "lyrics", - id = 99999999 - } - }, - new - { - op = "update", - data = new - { - type = "musicTracks", - lid = "doesNotExist", - attributes = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 'doesNotExist' is not available at this point."); - error.Source.Pointer.Should().Be("/atomic:operations[1]"); - } - - [Fact] - public async Task Cannot_consume_unassigned_local_ID_in_data_array() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "remove", - @ref = new - { - type = "lyrics", - id = 99999999 - } - }, - new - { - op = "add", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - lid = "doesNotExist" - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 'doesNotExist' is not available at this point."); - error.Source.Pointer.Should().Be("/atomic:operations[1]"); - } - - [Fact] - public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_element() - { - // Arrange - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "remove", - @ref = new - { - type = "lyrics", - id = 99999999 - } - }, - new - { - op = "add", - data = new - { - type = "musicTracks", - relationships = new - { - ownedBy = new - { - data = new - { - type = "recordCompanies", - lid = "doesNotExist" - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 'doesNotExist' is not available at this point."); - error.Source.Pointer.Should().Be("/atomic:operations[1]"); - } - - [Fact] - public async Task Cannot_consume_unassigned_local_ID_in_relationship_data_array() - { - // Arrange - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "remove", - @ref = new - { - type = "lyrics", - id = 99999999 - } - }, - new - { - op = "add", - data = new - { - type = "playlists", - relationships = new - { - tracks = new - { - data = new[] - { - new - { - type = "musicTracks", - lid = "doesNotExist" - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 'doesNotExist' is not available at this point."); - error.Source.Pointer.Should().Be("/atomic:operations[1]"); - } - - [Fact] - public async Task Cannot_consume_local_ID_of_different_type_in_same_operation() - { - // Arrange - const string trackLocalId = "track-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "remove", - @ref = new - { - type = "lyrics", - id = 99999999 - } - }, - new - { - op = "add", - data = new - { - type = "musicTracks", - lid = trackLocalId, - relationships = new - { - ownedBy = new - { - data = new - { - type = "recordCompanies", - lid = trackLocalId - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Type mismatch in local ID usage."); - error.Detail.Should().Be("Local ID 'track-1' belongs to resource type 'musicTracks' instead of 'recordCompanies'."); - error.Source.Pointer.Should().Be("/atomic:operations[1]"); - } - - [Fact] - public async Task Cannot_consume_local_ID_of_different_type_in_ref() - { - // Arrange - const string companyLocalId = "company-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "remove", - @ref = new - { - type = "lyrics", - id = 99999999 - } - }, - new - { - op = "add", - data = new - { - type = "recordCompanies", - lid = companyLocalId - } - }, - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - lid = companyLocalId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Type mismatch in local ID usage."); - error.Detail.Should().Be("Local ID 'company-1' belongs to resource type 'recordCompanies' instead of 'musicTracks'."); - error.Source.Pointer.Should().Be("/atomic:operations[2]"); - } - - [Fact] - public async Task Cannot_consume_local_ID_of_different_type_in_data_element() - { - // Arrange - const string performerLocalId = "performer-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "remove", - @ref = new - { - type = "lyrics", - id = 99999999 - } - }, - new - { - op = "add", - data = new - { - type = "performers", - lid = performerLocalId - } - }, - new - { - op = "update", - data = new - { - type = "playlists", - lid = performerLocalId, - attributes = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Type mismatch in local ID usage."); - error.Detail.Should().Be("Local ID 'performer-1' belongs to resource type 'performers' instead of 'playlists'."); - error.Source.Pointer.Should().Be("/atomic:operations[2]"); - } - - [Fact] - public async Task Cannot_consume_local_ID_of_different_type_in_data_array() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - - const string companyLocalId = "company-1"; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "remove", - @ref = new - { - type = "lyrics", - id = 99999999 - } - }, - new - { - op = "add", - data = new - { - type = "recordCompanies", - lid = companyLocalId - } - }, - new - { - op = "add", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - lid = companyLocalId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Type mismatch in local ID usage."); - error.Detail.Should().Be("Local ID 'company-1' belongs to resource type 'recordCompanies' instead of 'performers'."); - error.Source.Pointer.Should().Be("/atomic:operations[2]"); - } - - [Fact] - public async Task Cannot_consume_local_ID_of_different_type_in_relationship_data_element() - { - // Arrange - string newPlaylistName = _fakers.Playlist.Generate().Name; - - const string playlistLocalId = "playlist-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "remove", - @ref = new - { - type = "lyrics", - id = 99999999 - } - }, - new - { - op = "add", - data = new - { - type = "playlists", - lid = playlistLocalId, - attributes = new - { - name = newPlaylistName - } - } - }, - new - { - op = "add", - data = new - { - type = "musicTracks", - relationships = new - { - ownedBy = new - { - data = new - { - type = "recordCompanies", - lid = playlistLocalId - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Type mismatch in local ID usage."); - error.Detail.Should().Be("Local ID 'playlist-1' belongs to resource type 'playlists' instead of 'recordCompanies'."); - error.Source.Pointer.Should().Be("/atomic:operations[2]"); - } - - [Fact] - public async Task Cannot_consume_local_ID_of_different_type_in_relationship_data_array() - { - // Arrange - const string performerLocalId = "performer-1"; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "remove", - @ref = new - { - type = "lyrics", - id = 99999999 - } - }, - new - { - op = "add", - data = new - { - type = "performers", - lid = performerLocalId - } - }, - new - { - op = "add", - data = new - { - type = "playlists", - relationships = new - { - tracks = new - { - data = new[] - { - new - { - type = "musicTracks", - lid = performerLocalId - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Type mismatch in local ID usage."); - error.Detail.Should().Be("Local ID 'performer-1' belongs to resource type 'performers' instead of 'musicTracks'."); - error.Source.Pointer.Should().Be("/atomic:operations[2]"); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Lyric.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Lyric.cs deleted file mode 100644 index 380001f373..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Lyric.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Lyric : Identifiable - { - [Attr] - public string Format { get; set; } - - [Attr] - public string Text { get; set; } - - [Attr(Capabilities = AttrCapabilities.None)] - public DateTimeOffset CreatedAt { get; set; } - - [HasOne] - public TextLanguage Language { get; set; } - - [HasOne] - public MusicTrack Track { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/LyricsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/LyricsController.cs deleted file mode 100644 index c34c3de26c..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/LyricsController.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations -{ - public sealed class LyricsController : JsonApiController - { - public LyricsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs deleted file mode 100644 index bae079ffcb..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResourceMetaTests.cs +++ /dev/null @@ -1,157 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using FluentAssertions.Extensions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Controllers; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Meta -{ - public sealed class AtomicResourceMetaTests : IClassFixture, OperationsDbContext>> - { - private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new OperationsFakers(); - - public AtomicResourceMetaTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - - testContext.ConfigureServicesAfterStartup(services => - { - services.AddResourceDefinition(); - services.AddResourceDefinition(); - - services.AddSingleton(); - }); - - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - hitCounter.Reset(); - } - - [Fact] - 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; - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTitle1, - releasedAt = 1.January(2018) - } - } - }, - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTitle2, - releasedAt = 23.August(1994) - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(2); - - responseDocument.Results[0].SingleData.Meta.Should().HaveCount(1); - responseDocument.Results[0].SingleData.Meta["Copyright"].Should().Be("(C) 2018. All rights reserved."); - - responseDocument.Results[1].SingleData.Meta.Should().HaveCount(1); - responseDocument.Results[1].SingleData.Meta["Copyright"].Should().Be("(C) 1994. All rights reserved."); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(MusicTrack), ResourceDefinitionHitCounter.ExtensibilityPoint.GetMeta), - (typeof(MusicTrack), ResourceDefinitionHitCounter.ExtensibilityPoint.GetMeta) - }, options => options.WithStrictOrdering()); - } - - [Fact] - public async Task Returns_resource_meta_in_update_resource_with_side_effects() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - TextLanguage existingLanguage = _fakers.TextLanguage.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TextLanguages.Add(existingLanguage); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "textLanguages", - id = existingLanguage.StringId, - attributes = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].SingleData.Meta.Should().HaveCount(1); - responseDocument.Results[0].SingleData.Meta["Notice"].Should().Be(TextLanguageMetaDefinition.NoticeText); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(TextLanguage), ResourceDefinitionHitCounter.ExtensibilityPoint.GetMeta) - }, options => options.WithStrictOrdering()); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMeta.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMeta.cs deleted file mode 100644 index 09a59d5507..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMeta.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Serialization; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Meta -{ - public sealed class AtomicResponseMeta : IResponseMeta - { - public IReadOnlyDictionary GetMeta() - { - return new Dictionary - { - ["license"] = "MIT", - ["projectUrl"] = "https://github.com/json-api-dotnet/JsonApiDotNetCore/", - ["versions"] = new[] - { - "v4.0.0", - "v3.1.0", - "v2.5.2", - "v1.3.1" - } - }; - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs deleted file mode 100644 index facb8dc4a9..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/AtomicResponseMetaTests.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Controllers; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.Extensions.DependencyInjection; -using Newtonsoft.Json.Linq; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Meta -{ - public sealed class AtomicResponseMetaTests : IClassFixture, OperationsDbContext>> - { - private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new OperationsFakers(); - - public AtomicResponseMetaTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - - testContext.ConfigureServicesAfterStartup(services => - { - services.AddSingleton(); - }); - } - - [Fact] - public async Task Returns_top_level_meta_in_create_resource_with_side_effects() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "performers", - attributes = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Meta.Should().HaveCount(3); - responseDocument.Meta["license"].Should().Be("MIT"); - responseDocument.Meta["projectUrl"].Should().Be("https://github.com/json-api-dotnet/JsonApiDotNetCore/"); - - string[] versionArray = ((IEnumerable)responseDocument.Meta["versions"]).Select(token => token.ToString()).ToArray(); - - versionArray.Should().HaveCount(4); - versionArray.Should().Contain("v4.0.0"); - versionArray.Should().Contain("v3.1.0"); - versionArray.Should().Contain("v2.5.2"); - versionArray.Should().Contain("v1.3.1"); - } - - [Fact] - public async Task Returns_top_level_meta_in_update_resource_with_side_effects() - { - // Arrange - TextLanguage existingLanguage = _fakers.TextLanguage.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TextLanguages.Add(existingLanguage); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "textLanguages", - id = existingLanguage.StringId, - attributes = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Meta.Should().HaveCount(3); - responseDocument.Meta["license"].Should().Be("MIT"); - responseDocument.Meta["projectUrl"].Should().Be("https://github.com/json-api-dotnet/JsonApiDotNetCore/"); - - string[] versionArray = ((IEnumerable)responseDocument.Meta["versions"]).Select(token => token.ToString()).ToArray(); - - versionArray.Should().HaveCount(4); - versionArray.Should().Contain("v4.0.0"); - versionArray.Should().Contain("v3.1.0"); - versionArray.Should().Contain("v2.5.2"); - versionArray.Should().Contain("v1.3.1"); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/MusicTrackMetaDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/MusicTrackMetaDefinition.cs deleted file mode 100644 index 822e614a95..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/MusicTrackMetaDefinition.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Meta -{ - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class MusicTrackMetaDefinition : JsonApiResourceDefinition - { - private readonly ResourceDefinitionHitCounter _hitCounter; - - public MusicTrackMetaDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph) - { - _hitCounter = hitCounter; - } - - public override IDictionary GetMeta(MusicTrack resource) - { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.GetMeta); - - return new Dictionary - { - ["Copyright"] = $"(C) {resource.ReleasedAt.Year}. All rights reserved." - }; - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/TextLanguageMetaDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/TextLanguageMetaDefinition.cs deleted file mode 100644 index c1c2be73b4..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Meta/TextLanguageMetaDefinition.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System; -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Meta -{ - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class TextLanguageMetaDefinition : JsonApiResourceDefinition - { - internal const string NoticeText = "See https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes for ISO 639-1 language codes."; - - private readonly ResourceDefinitionHitCounter _hitCounter; - - public TextLanguageMetaDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph) - { - _hitCounter = hitCounter; - } - - public override IDictionary GetMeta(TextLanguage resource) - { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.GetMeta); - - return new Dictionary - { - ["Notice"] = NoticeText - }; - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs deleted file mode 100644 index 8b7084b4fe..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicRequestBodyTests.cs +++ /dev/null @@ -1,162 +0,0 @@ -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Controllers; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.EntityFrameworkCore; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Mixed -{ - public sealed class AtomicRequestBodyTests : IClassFixture, OperationsDbContext>> - { - private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; - - public AtomicRequestBodyTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task Cannot_process_for_missing_request_body() - { - // Arrange - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, null); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Missing request body."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().BeNull(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.Should().BeEmpty(); - - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().BeEmpty(); - }); - } - - [Fact] - public async Task Cannot_process_for_broken_JSON_request_body() - { - // Arrange - const string requestBody = "{\"atomic__operations\":[{\"op\":"; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); - error.Detail.Should().StartWith("Unexpected end of content while loading JObject."); - error.Source.Pointer.Should().BeNull(); - } - - [Fact] - public async Task Cannot_process_empty_operations_array() - { - // Arrange - var requestBody = new - { - atomic__operations = new object[0] - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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.Pointer.Should().BeNull(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.Should().BeEmpty(); - - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().BeEmpty(); - }); - } - - [Fact] - public async Task Cannot_process_for_unknown_operation_code() - { - // Arrange - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "merge", - data = new - { - type = "performers", - attributes = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); - error.Detail.Should().StartWith("Error converting value \"merge\" to type"); - error.Source.Pointer.Should().BeNull(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.Should().BeEmpty(); - - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().BeEmpty(); - }); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs deleted file mode 100644 index c9127a1158..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCoreExample.Controllers; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Mixed -{ - public sealed class AtomicSerializationTests : IClassFixture, OperationsDbContext>> - { - private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new OperationsFakers(); - - public AtomicSerializationTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - - // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. - testContext.UseController(); - - testContext.ConfigureServicesAfterStartup(services => - { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); - - var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); - options.IncludeJsonApiVersion = true; - options.AllowClientGeneratedIds = true; - } - - [Fact] - public async Task Includes_version_with_ext_on_operations_endpoint() - { - // Arrange - const int newArtistId = 12345; - string newArtistName = _fakers.Performer.Generate().ArtistName; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "performers", - id = newArtistId, - attributes = new - { - artistName = newArtistName - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Should().BeJson(@"{ - ""jsonapi"": { - ""version"": ""1.1"", - ""ext"": [ - ""https://jsonapi.org/ext/atomic"" - ] - }, - ""atomic:results"": [ - { - ""data"": { - ""type"": ""performers"", - ""id"": """ + newArtistId + @""", - ""attributes"": { - ""artistName"": """ + newArtistName + @""", - ""bornAt"": ""0001-01-01T01:00:00+01:00"" - }, - ""links"": { - ""self"": ""http://localhost/performers/" + newArtistId + @""" - } - } - } - ] -}"); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs deleted file mode 100644 index fcc49f7c2e..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/MaximumOperationsPerRequestTests.cs +++ /dev/null @@ -1,165 +0,0 @@ -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Controllers; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Mixed -{ - public sealed class MaximumOperationsPerRequestTests - : IClassFixture, OperationsDbContext>> - { - private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; - - public MaximumOperationsPerRequestTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task Cannot_process_more_operations_than_maximum() - { - // Arrange - var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); - options.MaximumOperationsPerRequest = 2; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - } - }, - new - { - op = "remove", - data = new - { - } - }, - new - { - op = "update", - data = new - { - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request exceeds the maximum number of operations."); - error.Detail.Should().Be("The number of operations in this request (3) is higher than 2."); - error.Source.Pointer.Should().BeNull(); - } - - [Fact] - public async Task Can_process_operations_same_as_maximum() - { - // Arrange - var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); - options.MaximumOperationsPerRequest = 2; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "performers", - attributes = new - { - } - } - }, - new - { - op = "add", - data = new - { - type = "performers", - attributes = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - } - - [Fact] - public async Task Can_process_high_number_of_operations_when_unconstrained() - { - // Arrange - var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); - options.MaximumOperationsPerRequest = null; - - const int elementCount = 100; - - var operationElements = new List(elementCount); - - for (int index = 0; index < elementCount; index++) - { - operationElements.Add(new - { - op = "add", - data = new - { - type = "performers", - attributes = new - { - } - } - }); - } - - var requestBody = new - { - atomic__operations = operationElements - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs deleted file mode 100644 index a86bef8595..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ModelStateValidation/AtomicModelStateValidationTests.cs +++ /dev/null @@ -1,514 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Controllers; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.EntityFrameworkCore; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ModelStateValidation -{ - public sealed class AtomicModelStateValidationTests - : IClassFixture, OperationsDbContext>> - { - private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new OperationsFakers(); - - public AtomicModelStateValidationTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task Cannot_create_resource_with_multiple_violations() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - lengthInSeconds = -1 - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(2); - - Error 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.Pointer.Should().Be("/atomic:operations[0]/data/attributes/title"); - - Error 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.Pointer.Should().Be("/atomic:operations[0]/data/attributes/lengthInSeconds"); - } - - [Fact] - public async Task Can_create_resource_with_annotated_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - string newPlaylistName = _fakers.Playlist.Generate().Name; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "playlists", - attributes = new - { - name = newPlaylistName - }, - relationships = new - { - tracks = new - { - data = new[] - { - new - { - type = "musicTracks", - id = existingTrack.StringId - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(1); - - long newPlaylistId = long.Parse(responseDocument.Results[0].SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - Playlist playlistInDatabase = await dbContext.Playlists - .Include(playlist => playlist.PlaylistMusicTracks) - .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) - .FirstWithIdAsync(newPlaylistId); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(1); - playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Id.Should().Be(existingTrack.Id); - }); - } - - [Fact] - public async Task Cannot_update_resource_with_multiple_violations() - { - // 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 - { - title = (string)null, - lengthInSeconds = -1 - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(2); - - Error 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.Pointer.Should().Be("/atomic:operations[0]/data/attributes/title"); - - Error 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.Pointer.Should().Be("/atomic:operations[0]/data/attributes/lengthInSeconds"); - } - - [Fact] - public async Task Can_update_resource_with_omitted_required_attribute() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - string newTrackGenre = _fakers.MusicTrack.Generate().Genre; - - 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 - { - genre = newTrackGenre - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Title.Should().Be(existingTrack.Title); - trackInDatabase.Genre.Should().Be(newTrackGenre); - }); - } - - [Fact] - public async Task Can_update_resource_with_annotated_relationship() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingPlaylist, existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationships = new - { - tracks = new - { - data = new[] - { - new - { - type = "musicTracks", - id = existingTrack.StringId - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - Playlist playlistInDatabase = await dbContext.Playlists - .Include(playlist => playlist.PlaylistMusicTracks) - .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) - .FirstWithIdAsync(existingPlaylist.Id); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(1); - playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Id.Should().Be(existingTrack.Id); - }); - } - - [Fact] - public async Task Can_update_ToOne_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingTrack, existingCompany); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "ownedBy" - }, - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.OwnedBy.Should().NotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); - }); - } - - [Fact] - public async Task Can_update_ToMany_relationship() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingPlaylist, existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = existingTrack.StringId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - Playlist playlistInDatabase = await dbContext.Playlists - .Include(playlist => playlist.PlaylistMusicTracks) - .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) - .FirstWithIdAsync(existingPlaylist.Id); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(1); - playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Id.Should().Be(existingTrack.Id); - }); - } - - [Fact] - public async Task Validates_all_operations_before_execution_starts() - { - // Arrange - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "update", - data = new - { - type = "playlists", - id = 99999999, - attributes = new - { - name = (string)null - } - } - }, - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - lengthInSeconds = -1 - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(3); - - Error 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.Pointer.Should().Be("/atomic:operations[0]/data/attributes/name"); - - Error error2 = responseDocument.Errors[1]; - error2.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error2.Title.Should().Be("Input validation failed."); - error2.Detail.Should().Be("The Title field is required."); - error2.Source.Pointer.Should().Be("/atomic:operations[1]/data/attributes/title"); - - Error error3 = responseDocument.Errors[2]; - error3.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error3.Title.Should().Be("Input validation failed."); - error3.Detail.Should().Be("The field LengthInSeconds must be between 1 and 1440."); - error3.Source.Pointer.Should().Be("/atomic:operations[1]/data/attributes/lengthInSeconds"); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTrack.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTrack.cs deleted file mode 100644 index 94733028d9..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTrack.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class MusicTrack : Identifiable - { - [RegularExpression(@"(?im)^[{(]?[0-9A-F]{8}[-]?(?:[0-9A-F]{4}[-]?){3}[0-9A-F]{12}[)}]?$")] - public override Guid Id { get; set; } - - [Attr] - [Required] - public string Title { get; set; } - - [Attr] - [Range(1, 24 * 60)] - public decimal? LengthInSeconds { get; set; } - - [Attr] - public string Genre { get; set; } - - [Attr] - public DateTimeOffset ReleasedAt { get; set; } - - [HasOne] - public Lyric Lyric { get; set; } - - [HasOne] - public RecordCompany OwnedBy { get; set; } - - [HasMany] - public IList Performers { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTracksController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTracksController.cs deleted file mode 100644 index ed2ffd6044..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/MusicTracksController.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations -{ - public sealed class MusicTracksController : JsonApiController - { - public MusicTracksController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs deleted file mode 100644 index ad45e3bb85..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsDbContext.cs +++ /dev/null @@ -1,39 +0,0 @@ -using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore; - -// @formatter:wrap_chained_method_calls chop_always - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class OperationsDbContext : DbContext - { - public DbSet Playlists { get; set; } - public DbSet MusicTracks { get; set; } - public DbSet PlaylistMusicTracks { get; set; } - public DbSet Lyrics { get; set; } - public DbSet TextLanguages { get; set; } - public DbSet Performers { get; set; } - public DbSet RecordCompanies { get; set; } - - public OperationsDbContext(DbContextOptions options) - : base(options) - { - } - - protected override void OnModelCreating(ModelBuilder builder) - { - builder.Entity() - .HasKey(playlistMusicTrack => new - { - playlistMusicTrack.PlaylistId, - playlistMusicTrack.MusicTrackId - }); - - builder.Entity() - .HasOne(musicTrack => musicTrack.Lyric) - .WithOne(lyric => lyric.Track) - .HasForeignKey("LyricId"); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsFakers.cs deleted file mode 100644 index 546cb84b03..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/OperationsFakers.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Linq; -using Bogus; -using TestBuildingBlocks; - -// @formatter:wrap_chained_method_calls chop_always -// @formatter:keep_existing_linebreaks true - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations -{ - internal sealed class OperationsFakers : FakerContainer - { - private static readonly Lazy> LazyLanguageIsoCodes = - new Lazy>(() => CultureInfo - .GetCultures(CultureTypes.NeutralCultures) - .Where(culture => !string.IsNullOrEmpty(culture.Name)) - .Select(culture => culture.Name) - .ToArray()); - - private readonly Lazy> _lazyPlaylistFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(playlist => playlist.Name, faker => faker.Lorem.Sentence())); - - private readonly Lazy> _lazyMusicTrackFaker = new Lazy>(() => - 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())); - - private readonly Lazy> _lazyLyricFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(lyric => lyric.Text, faker => faker.Lorem.Text()) - .RuleFor(lyric => lyric.Format, "LRC")); - - private readonly Lazy> _lazyTextLanguageFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(textLanguage => textLanguage.IsoCode, faker => faker.PickRandom(LazyLanguageIsoCodes.Value))); - - private readonly Lazy> _lazyPerformerFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(performer => performer.ArtistName, faker => faker.Name.FullName()) - .RuleFor(performer => performer.BornAt, faker => faker.Date.PastOffset())); - - private readonly Lazy> _lazyRecordCompanyFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(recordCompany => recordCompany.Name, faker => faker.Company.CompanyName()) - .RuleFor(recordCompany => recordCompany.CountryOfResidence, faker => faker.Address.Country())); - - public Faker Playlist => _lazyPlaylistFaker.Value; - public Faker MusicTrack => _lazyMusicTrackFaker.Value; - public Faker Lyric => _lazyLyricFaker.Value; - public Faker TextLanguage => _lazyTextLanguageFaker.Value; - public Faker Performer => _lazyPerformerFaker.Value; - public Faker RecordCompany => _lazyRecordCompanyFaker.Value; - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Performer.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Performer.cs deleted file mode 100644 index 37a0f0c16b..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Performer.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Performer : Identifiable - { - [Attr] - public string ArtistName { get; set; } - - [Attr] - public DateTimeOffset BornAt { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/PerformersController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/PerformersController.cs deleted file mode 100644 index 76eb4c9fe8..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/PerformersController.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations -{ - public sealed class PerformersController : JsonApiController - { - public PerformersController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Playlist.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Playlist.cs deleted file mode 100644 index 5b0713c45b..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Playlist.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Playlist : Identifiable - { - [Attr] - [Required] - public string Name { get; set; } - - [NotMapped] - [Attr] - public bool IsArchived => false; - - [NotMapped] - [HasManyThrough(nameof(PlaylistMusicTracks))] - public IList Tracks { get; set; } - - public IList PlaylistMusicTracks { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/PlaylistMusicTrack.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/PlaylistMusicTrack.cs deleted file mode 100644 index 47540cafdf..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/PlaylistMusicTrack.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using JetBrains.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class PlaylistMusicTrack - { - public long PlaylistId { get; set; } - public Playlist Playlist { get; set; } - - public Guid MusicTrackId { get; set; } - public MusicTrack MusicTrack { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/PlaylistsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/PlaylistsController.cs deleted file mode 100644 index 6ec97e341a..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/PlaylistsController.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations -{ - public sealed class PlaylistsController : JsonApiController - { - public PlaylistsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs deleted file mode 100644 index 63e0d007e8..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/AtomicQueryStringTests.cs +++ /dev/null @@ -1,443 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using FluentAssertions.Extensions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Controllers; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.AspNetCore.Authentication; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.QueryStrings -{ - public sealed class AtomicQueryStringTests : IClassFixture, OperationsDbContext>> - { - private static readonly DateTime FrozenTime = 30.July(2018).At(13, 46, 12); - - private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new OperationsFakers(); - - public AtomicQueryStringTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - testContext.UseController(); - - testContext.ConfigureServicesAfterStartup(services => - { - services.AddSingleton(new FrozenSystemClock - { - UtcNow = FrozenTime - }); - - services.AddResourceDefinition(); - }); - - var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); - options.AllowQueryStringOverrideForSerializerDefaultValueHandling = true; - options.AllowQueryStringOverrideForSerializerNullValueHandling = true; - } - - [Fact] - public async Task Cannot_include_on_operations_endpoint() - { - // Arrange - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "recordCompanies", - attributes = new - { - } - } - } - } - }; - - const string route = "/operations?include=recordCompanies"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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.Parameter.Should().Be("include"); - } - - [Fact] - public async Task Cannot_filter_on_operations_endpoint() - { - // Arrange - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "recordCompanies", - attributes = new - { - } - } - } - } - }; - - const string route = "/operations?filter=equals(id,'1')"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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.Parameter.Should().Be("filter"); - } - - [Fact] - public async Task Cannot_sort_on_operations_endpoint() - { - // Arrange - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "recordCompanies", - attributes = new - { - } - } - } - } - }; - - const string route = "/operations?sort=-id"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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.Parameter.Should().Be("sort"); - } - - [Fact] - public async Task Cannot_use_pagination_number_on_operations_endpoint() - { - // Arrange - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "recordCompanies", - attributes = new - { - } - } - } - } - }; - - const string route = "/operations?page[number]=1"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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.Parameter.Should().Be("page[number]"); - } - - [Fact] - public async Task Cannot_use_pagination_size_on_operations_endpoint() - { - // Arrange - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "recordCompanies", - attributes = new - { - } - } - } - } - }; - - const string route = "/operations?page[size]=1"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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.Parameter.Should().Be("page[size]"); - } - - [Fact] - public async Task Cannot_use_sparse_fieldset_on_operations_endpoint() - { - // Arrange - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "recordCompanies", - attributes = new - { - } - } - } - } - }; - - const string route = "/operations?fields[recordCompanies]=id"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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.Parameter.Should().Be("fields[recordCompanies]"); - } - - [Fact] - public async Task Can_use_Queryable_handler_on_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); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.AddRange(musicTracks); - await dbContext.SaveChangesAsync(); - }); - - const string route = "/musicTracks?isRecentlyReleased=true"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Id.Should().Be(musicTracks[2].StringId); - } - - [Fact] - public async Task Cannot_use_Queryable_handler_on_operations_endpoint() - { - // Arrange - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTrackTitle - } - } - } - } - }; - - const string route = "/operations?isRecentlyReleased=true"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Unknown query string parameter."); - - error.Detail.Should().Be("Query string parameter 'isRecentlyReleased' is unknown. " + - "Set 'AllowUnknownQueryStringParameters' to 'true' in options to ignore unknown parameters."); - - error.Source.Parameter.Should().Be("isRecentlyReleased"); - } - - [Fact] - public async Task Can_use_defaults_on_operations_endpoint() - { - // Arrange - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - decimal? newTrackLength = _fakers.MusicTrack.Generate().LengthInSeconds; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTrackTitle, - lengthInSeconds = newTrackLength - } - } - } - } - }; - - const string route = "/operations?defaults=false"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].SingleData.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Type.Should().Be("musicTracks"); - responseDocument.Results[0].SingleData.Attributes.Should().HaveCount(2); - responseDocument.Results[0].SingleData.Attributes["title"].Should().Be(newTrackTitle); - responseDocument.Results[0].SingleData.Attributes["lengthInSeconds"].As().Should().BeApproximately(newTrackLength); - } - - [Fact] - public async Task Can_use_nulls_on_operations_endpoint() - { - // Arrange - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - decimal? newTrackLength = _fakers.MusicTrack.Generate().LengthInSeconds; - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTrackTitle, - lengthInSeconds = newTrackLength - } - } - } - } - }; - - const string route = "/operations?nulls=false"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].SingleData.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Type.Should().Be("musicTracks"); - responseDocument.Results[0].SingleData.Attributes.Should().HaveCount(2); - responseDocument.Results[0].SingleData.Attributes["title"].Should().Be(newTrackTitle); - responseDocument.Results[0].SingleData.Attributes["lengthInSeconds"].As().Should().BeApproximately(newTrackLength); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/MusicTrackReleaseDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/MusicTrackReleaseDefinition.cs deleted file mode 100644 index 857f68ef25..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/QueryStrings/MusicTrackReleaseDefinition.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; -using System.Linq; -using JetBrains.Annotations; -using JsonApiDotNetCore; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; -using Microsoft.AspNetCore.Authentication; -using Microsoft.Extensions.Primitives; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.QueryStrings -{ - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class MusicTrackReleaseDefinition : JsonApiResourceDefinition - { - private readonly ISystemClock _systemClock; - - public MusicTrackReleaseDefinition(IResourceGraph resourceGraph, ISystemClock systemClock) - : base(resourceGraph) - { - ArgumentGuard.NotNull(systemClock, nameof(systemClock)); - - _systemClock = systemClock; - } - - public override QueryStringParameterHandlers OnRegisterQueryableHandlersForQueryStringParameters() - { - return new QueryStringParameterHandlers - { - ["isRecentlyReleased"] = FilterOnRecentlyReleased - }; - } - - private IQueryable FilterOnRecentlyReleased(IQueryable source, StringValues parameterValue) - { - IQueryable tracks = source; - - if (bool.Parse(parameterValue)) - { - tracks = tracks.Where(musicTrack => musicTrack.ReleasedAt < _systemClock.UtcNow && musicTrack.ReleasedAt > _systemClock.UtcNow.AddMonths(-3)); - } - - return tracks; - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/RecordCompaniesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/RecordCompaniesController.cs deleted file mode 100644 index a0c55aeb24..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/RecordCompaniesController.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations -{ - public sealed class RecordCompaniesController : JsonApiController - { - public RecordCompaniesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/RecordCompany.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/RecordCompany.cs deleted file mode 100644 index d735fe4d7b..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/RecordCompany.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class RecordCompany : Identifiable - { - [Attr] - public string Name { get; set; } - - [Attr] - public string CountryOfResidence { get; set; } - - [HasMany] - public IList Tracks { get; set; } - - [HasOne] - public RecordCompany Parent { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs deleted file mode 100644 index c0e2408d63..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/AtomicSerializationResourceDefinitionTests.cs +++ /dev/null @@ -1,379 +0,0 @@ -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Controllers; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ResourceDefinitions.Serialization -{ - public sealed class AtomicSerializationResourceDefinitionTests - : IClassFixture, OperationsDbContext>> - { - private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new OperationsFakers(); - - public AtomicSerializationResourceDefinitionTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - - testContext.ConfigureServicesAfterStartup(services => - { - services.AddResourceDefinition(); - - services.AddSingleton(); - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); - - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - hitCounter.Reset(); - } - - [Fact] - public async Task Transforms_on_create_resource_with_side_effects() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - List newCompanies = _fakers.RecordCompany.Generate(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "recordCompanies", - attributes = new - { - name = newCompanies[0].Name, - countryOfResidence = newCompanies[0].CountryOfResidence - } - } - }, - new - { - op = "add", - data = new - { - type = "recordCompanies", - attributes = new - { - name = newCompanies[1].Name, - countryOfResidence = newCompanies[1].CountryOfResidence - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(2); - - responseDocument.Results[0].SingleData.Attributes["name"].Should().Be(newCompanies[0].Name.ToUpperInvariant()); - responseDocument.Results[0].SingleData.Attributes["countryOfResidence"].Should().Be(newCompanies[0].CountryOfResidence.ToUpperInvariant()); - - responseDocument.Results[1].SingleData.Attributes["name"].Should().Be(newCompanies[1].Name.ToUpperInvariant()); - responseDocument.Results[1].SingleData.Attributes["countryOfResidence"].Should().Be(newCompanies[1].CountryOfResidence.ToUpperInvariant()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.Should().HaveCount(2); - - companiesInDatabase[0].Name.Should().Be(newCompanies[0].Name.ToUpperInvariant()); - companiesInDatabase[0].CountryOfResidence.Should().Be(newCompanies[0].CountryOfResidence); - - companiesInDatabase[1].Name.Should().Be(newCompanies[1].Name.ToUpperInvariant()); - companiesInDatabase[1].CountryOfResidence.Should().Be(newCompanies[1].CountryOfResidence); - }); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(RecordCompany), ResourceDefinitionHitCounter.ExtensibilityPoint.OnDeserialize), - (typeof(RecordCompany), ResourceDefinitionHitCounter.ExtensibilityPoint.OnDeserialize), - (typeof(RecordCompany), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSerialize), - (typeof(RecordCompany), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSerialize) - }, options => options.WithStrictOrdering()); - } - - [Fact] - public async Task Skips_on_create_resource_with_ToOne_relationship() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - - string newTrackTitle = _fakers.MusicTrack.Generate().Title; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.RecordCompanies.Add(existingCompany); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTrackTitle - }, - relationships = new - { - ownedBy = new - { - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(1); - - hitCounter.HitExtensibilityPoints.Should().BeEmpty(); - } - - [Fact] - public async Task Transforms_on_update_resource_with_side_effects() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - List existingCompanies = _fakers.RecordCompany.Generate(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.RecordCompanies.AddRange(existingCompanies); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "recordCompanies", - id = existingCompanies[0].StringId, - attributes = new - { - } - } - }, - new - { - op = "update", - data = new - { - type = "recordCompanies", - id = existingCompanies[1].StringId, - attributes = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(2); - - responseDocument.Results[0].SingleData.Attributes["name"].Should().Be(existingCompanies[0].Name); - responseDocument.Results[0].SingleData.Attributes["countryOfResidence"].Should().Be(existingCompanies[0].CountryOfResidence.ToUpperInvariant()); - - responseDocument.Results[1].SingleData.Attributes["name"].Should().Be(existingCompanies[1].Name); - responseDocument.Results[1].SingleData.Attributes["countryOfResidence"].Should().Be(existingCompanies[1].CountryOfResidence.ToUpperInvariant()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.Should().HaveCount(2); - - companiesInDatabase[0].Name.Should().Be(existingCompanies[0].Name); - companiesInDatabase[0].CountryOfResidence.Should().Be(existingCompanies[0].CountryOfResidence); - - companiesInDatabase[1].Name.Should().Be(existingCompanies[1].Name); - companiesInDatabase[1].CountryOfResidence.Should().Be(existingCompanies[1].CountryOfResidence); - }); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(RecordCompany), ResourceDefinitionHitCounter.ExtensibilityPoint.OnDeserialize), - (typeof(RecordCompany), ResourceDefinitionHitCounter.ExtensibilityPoint.OnDeserialize), - (typeof(RecordCompany), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSerialize), - (typeof(RecordCompany), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSerialize) - }, options => options.WithStrictOrdering()); - } - - [Fact] - 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(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingTrack, existingCompany); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - attributes = new - { - }, - relationships = new - { - ownedBy = new - { - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(1); - - hitCounter.HitExtensibilityPoints.Should().BeEmpty(); - } - - [Fact] - 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(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingTrack, existingCompany); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "ownedBy" - }, - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEmpty(); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/RecordCompanyDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/RecordCompanyDefinition.cs deleted file mode 100644 index df221f521a..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/Serialization/RecordCompanyDefinition.cs +++ /dev/null @@ -1,38 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ResourceDefinitions.Serialization -{ - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class RecordCompanyDefinition : JsonApiResourceDefinition - { - private readonly ResourceDefinitionHitCounter _hitCounter; - - public RecordCompanyDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph) - { - _hitCounter = hitCounter; - } - - public override void OnDeserialize(RecordCompany resource) - { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.OnDeserialize); - - if (!string.IsNullOrEmpty(resource.Name)) - { - resource.Name = resource.Name.ToUpperInvariant(); - } - } - - public override void OnSerialize(RecordCompany resource) - { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.OnSerialize); - - if (!string.IsNullOrEmpty(resource.CountryOfResidence)) - { - resource.CountryOfResidence = resource.CountryOfResidence.ToUpperInvariant(); - } - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs deleted file mode 100644 index db9c793038..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/AtomicSparseFieldSetResourceDefinitionTests.cs +++ /dev/null @@ -1,186 +0,0 @@ -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Controllers; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ResourceDefinitions.SparseFieldSets -{ - public sealed class AtomicSparseFieldSetResourceDefinitionTests - : IClassFixture, OperationsDbContext>> - { - private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new OperationsFakers(); - - public AtomicSparseFieldSetResourceDefinitionTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - - testContext.ConfigureServicesAfterStartup(services => - { - services.AddResourceDefinition(); - - services.AddSingleton(); - services.AddSingleton(); - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); - - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - hitCounter.Reset(); - } - - [Fact] - public async Task Hides_text_in_create_resource_with_side_effects() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - var provider = _testContext.Factory.Services.GetRequiredService(); - provider.CanViewText = false; - - List newLyrics = _fakers.Lyric.Generate(2); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "lyrics", - attributes = new - { - format = newLyrics[0].Format, - text = newLyrics[0].Text - } - } - }, - new - { - op = "add", - data = new - { - type = "lyrics", - attributes = new - { - format = newLyrics[1].Format, - text = newLyrics[1].Text - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(2); - - responseDocument.Results[0].SingleData.Attributes["format"].Should().Be(newLyrics[0].Format); - responseDocument.Results[0].SingleData.Attributes.Should().NotContainKey("text"); - - responseDocument.Results[1].SingleData.Attributes["format"].Should().Be(newLyrics[1].Format); - responseDocument.Results[1].SingleData.Attributes.Should().NotContainKey("text"); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(Lyric), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet), - (typeof(Lyric), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet), - (typeof(Lyric), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet), - (typeof(Lyric), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet) - }, options => options.WithStrictOrdering()); - } - - [Fact] - public async Task Hides_text_in_update_resource_with_side_effects() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - var provider = _testContext.Factory.Services.GetRequiredService(); - provider.CanViewText = false; - - List existingLyrics = _fakers.Lyric.Generate(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Lyrics.AddRange(existingLyrics); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "lyrics", - id = existingLyrics[0].StringId, - attributes = new - { - } - } - }, - new - { - op = "update", - data = new - { - type = "lyrics", - id = existingLyrics[1].StringId, - attributes = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(2); - - responseDocument.Results[0].SingleData.Attributes["format"].Should().Be(existingLyrics[0].Format); - responseDocument.Results[0].SingleData.Attributes.Should().NotContainKey("text"); - - responseDocument.Results[1].SingleData.Attributes["format"].Should().Be(existingLyrics[1].Format); - responseDocument.Results[1].SingleData.Attributes.Should().NotContainKey("text"); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(Lyric), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet), - (typeof(Lyric), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet), - (typeof(Lyric), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet), - (typeof(Lyric), ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet) - }, options => options.WithStrictOrdering()); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricPermissionProvider.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricPermissionProvider.cs deleted file mode 100644 index 5593e187d3..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricPermissionProvider.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ResourceDefinitions.SparseFieldSets -{ - public sealed class LyricPermissionProvider - { - internal bool CanViewText { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricTextDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricTextDefinition.cs deleted file mode 100644 index a1943625ea..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/ResourceDefinitions/SparseFieldSets/LyricTextDefinition.cs +++ /dev/null @@ -1,30 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.ResourceDefinitions.SparseFieldSets -{ - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class LyricTextDefinition : JsonApiResourceDefinition - { - private readonly LyricPermissionProvider _lyricPermissionProvider; - private readonly ResourceDefinitionHitCounter _hitCounter; - - public LyricTextDefinition(IResourceGraph resourceGraph, LyricPermissionProvider lyricPermissionProvider, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph) - { - _lyricPermissionProvider = lyricPermissionProvider; - _hitCounter = hitCounter; - } - - public override SparseFieldSetExpression OnApplySparseFieldSet(SparseFieldSetExpression existingSparseFieldSet) - { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.OnApplySparseFieldSet); - - return _lyricPermissionProvider.CanViewText - ? base.OnApplySparseFieldSet(existingSparseFieldSet) - : existingSparseFieldSet.Excluding(lyric => lyric.Text, ResourceGraph); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/TextLanguage.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/TextLanguage.cs deleted file mode 100644 index be25c89b7d..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/TextLanguage.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations.Schema; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class TextLanguage : Identifiable - { - [Attr] - public string IsoCode { get; set; } - - [NotMapped] - [Attr(Capabilities = AttrCapabilities.None)] - public Guid ConcurrencyToken - { - get => Guid.NewGuid(); - set => _ = value; - } - - [HasMany] - public ICollection Lyrics { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/TextLanguagesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/TextLanguagesController.cs deleted file mode 100644 index eb01382927..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/TextLanguagesController.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations -{ - public sealed class TextLanguagesController : JsonApiController - { - public TextLanguagesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs deleted file mode 100644 index 2f01f4aaa2..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/AtomicRollbackTests.cs +++ /dev/null @@ -1,113 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Controllers; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.EntityFrameworkCore; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Transactions -{ - public sealed class AtomicRollbackTests : IClassFixture, OperationsDbContext>> - { - private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new OperationsFakers(); - - public AtomicRollbackTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task Can_rollback_on_error() - { - // Arrange - string newArtistName = _fakers.Performer.Generate().ArtistName; - DateTimeOffset newBornAt = _fakers.Performer.Generate().BornAt; - string newTitle = _fakers.MusicTrack.Generate().Title; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTablesAsync(); - }); - - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "performers", - attributes = new - { - artistName = newArtistName, - bornAt = newBornAt - } - } - }, - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - title = newTitle - }, - relationships = new - { - performers = new - { - data = new[] - { - new - { - type = "performers", - id = 99999999 - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 '99999999' in relationship 'performers' does not exist."); - error.Source.Pointer.Should().Be("/atomic:operations[1]"); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.Should().BeEmpty(); - - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().BeEmpty(); - }); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs deleted file mode 100644 index 4f6508fe4c..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/AtomicTransactionConsistencyTests.cs +++ /dev/null @@ -1,158 +0,0 @@ -using System; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Controllers; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Transactions -{ - public sealed class AtomicTransactionConsistencyTests - : IClassFixture, OperationsDbContext>> - { - private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; - - public AtomicTransactionConsistencyTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - - testContext.ConfigureServicesAfterStartup(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}"; - - services.AddDbContext(options => options.UseNpgsql(dbConnectionString)); - }); - } - - [Fact] - public async Task Cannot_use_non_transactional_repository() - { - // Arrange - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "performers", - attributes = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_use_transactional_repository_without_active_transaction() - { - // Arrange - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "musicTracks", - attributes = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_use_distributed_transaction() - { - // Arrange - var requestBody = new - { - atomic__operations = new object[] - { - new - { - op = "add", - data = new - { - type = "lyrics", - attributes = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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.Pointer.Should().Be("/atomic:operations[0]"); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/ExtraDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/ExtraDbContext.cs deleted file mode 100644 index fee681e5e3..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/ExtraDbContext.cs +++ /dev/null @@ -1,14 +0,0 @@ -using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Transactions -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class ExtraDbContext : DbContext - { - public ExtraDbContext(DbContextOptions options) - : base(options) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/LyricRepository.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/LyricRepository.cs deleted file mode 100644 index 9854ca9397..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/LyricRepository.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Repositories; -using JsonApiDotNetCore.Resources; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Transactions -{ - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class LyricRepository : EntityFrameworkCoreRepository - { - private readonly ExtraDbContext _extraDbContext; - - public override string TransactionId => _extraDbContext.Database.CurrentTransaction.TransactionId.ToString(); - - public LyricRepository(ExtraDbContext extraDbContext, ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory) - { - _extraDbContext = extraDbContext; - - extraDbContext.Database.EnsureCreated(); - extraDbContext.Database.BeginTransaction(); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/MusicTrackRepository.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/MusicTrackRepository.cs deleted file mode 100644 index 044cfa0a14..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/MusicTrackRepository.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System; -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Repositories; -using JsonApiDotNetCore.Resources; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Transactions -{ - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class MusicTrackRepository : EntityFrameworkCoreRepository - { - public override string TransactionId => null; - - public MusicTrackRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/PerformerRepository.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/PerformerRepository.cs deleted file mode 100644 index 555c9ba955..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Transactions/PerformerRepository.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using JetBrains.Annotations; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Repositories; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Transactions -{ - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class PerformerRepository : IResourceRepository - { - public Task> GetAsync(QueryLayer layer, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task CountAsync(FilterExpression topFilter, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task GetForCreateAsync(int id, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task CreateAsync(Performer resourceFromRequest, Performer resourceForDatabase, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task GetForUpdateAsync(QueryLayer queryLayer, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task UpdateAsync(Performer resourceFromRequest, Performer resourceFromDatabase, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task DeleteAsync(int id, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task SetRelationshipAsync(Performer primaryResource, object secondaryResourceIds, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task AddToToManyRelationshipAsync(int primaryId, ISet secondaryResourceIds, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - - public Task RemoveFromToManyRelationshipAsync(Performer primaryResource, ISet secondaryResourceIds, CancellationToken cancellationToken) - { - throw new NotImplementedException(); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs deleted file mode 100644 index 98d15f5459..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicAddToToManyRelationshipTests.cs +++ /dev/null @@ -1,972 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Controllers; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.EntityFrameworkCore; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Updating.Relationships -{ - public sealed class AtomicAddToToManyRelationshipTests - : IClassFixture, OperationsDbContext>> - { - private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new OperationsFakers(); - - public AtomicAddToToManyRelationshipTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task Cannot_add_to_HasOne_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingTrack, existingCompany); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "ownedBy" - }, - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Only to-many relationships can be targeted in 'add' operations."); - error.Detail.Should().Be("Relationship 'ownedBy' must be a to-many relationship."); - } - - [Fact] - public async Task Can_add_to_HasMany_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Performers = _fakers.Performer.Generate(1); - - List existingPerformers = _fakers.Performer.Generate(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.MusicTracks.Add(existingTrack); - dbContext.Performers.AddRange(existingPerformers); - 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 = existingPerformers[0].StringId - } - } - }, - new - { - op = "add", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - id = existingPerformers[1].StringId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - - 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); - }); - } - - [Fact] - public async Task Can_add_to_HasManyThrough_relationship() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); - - existingPlaylist.PlaylistMusicTracks = new List - { - new PlaylistMusicTrack - { - MusicTrack = _fakers.MusicTrack.Generate() - } - }; - - List existingTracks = _fakers.MusicTrack.Generate(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Playlists.Add(existingPlaylist); - dbContext.MusicTracks.AddRange(existingTracks); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = existingTracks[0].StringId - } - } - }, - new - { - op = "add", - @ref = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = existingTracks[1].StringId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - Playlist playlistInDatabase = await dbContext.Playlists - .Include(playlist => playlist.PlaylistMusicTracks) - .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) - .FirstWithIdAsync(existingPlaylist.Id); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - Guid initialTrackId = existingPlaylist.PlaylistMusicTracks[0].MusicTrack.Id; - - playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(3); - playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == initialTrackId); - playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[0].Id); - playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[1].Id); - }); - } - - [Fact] - public async Task Cannot_add_for_href_element() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - href = "/api/v1/musicTracks/1/relationships/performers" - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Usage of the 'href' element is not supported."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_add_for_missing_type_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - id = 99999999, - relationship = "tracks" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.type' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_add_for_unknown_type_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "doesNotExist", - id = 99999999, - relationship = "tracks" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); - error.Detail.Should().Be("Resource type 'doesNotExist' does not exist."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_add_for_missing_ID_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "musicTracks", - relationship = "performers" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_add_for_unknown_ID_in_ref() - { - // 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 = "add", - @ref = new - { - type = "recordCompanies", - id = 9999, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = existingTrack.StringId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 '9999' does not exist."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_add_for_ID_and_local_ID_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "musicTracks", - id = Guid.NewGuid().ToString(), - lid = "local-1", - relationship = "performers" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_add_for_missing_relationship_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - id = 99999999, - type = "musicTracks" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.relationship' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_add_for_unknown_relationship_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "performers", - id = 99999999, - relationship = "doesNotExist" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The referenced relationship does not exist."); - error.Detail.Should().Be("Resource of type 'performers' does not contain a relationship named 'doesNotExist'."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_add_for_null_data() - { - // 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 = "add", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = (object)null - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected data[] element for to-many relationship."); - error.Detail.Should().Be("Expected data[] element for 'performers' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_add_for_missing_type_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "playlists", - id = 99999999, - relationship = "tracks" - }, - data = new[] - { - new - { - id = Guid.NewGuid().ToString() - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data[].type' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_add_for_unknown_type_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "musicTracks", - id = Guid.NewGuid().ToString(), - relationship = "performers" - }, - data = new[] - { - new - { - type = "doesNotExist", - id = 99999999 - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); - error.Detail.Should().Be("Resource type 'doesNotExist' does not exist."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_add_for_missing_ID_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "musicTracks", - id = Guid.NewGuid().ToString(), - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers" - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data[].id' or 'data[].lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_add_for_ID_and_local_ID_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "musicTracks", - id = Guid.NewGuid().ToString(), - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - id = 99999999, - lid = "local-1" - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data[].id' or 'data[].lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_add_for_unknown_IDs_in_data() - { - // Arrange - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - Guid[] trackIds = ArrayFactory.Create(Guid.NewGuid(), Guid.NewGuid()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.RecordCompanies.Add(existingCompany); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - @ref = new - { - type = "recordCompanies", - id = existingCompany.StringId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = trackIds[0].ToString() - }, - new - { - type = "musicTracks", - id = trackIds[1].ToString() - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(2); - - Error 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.Pointer.Should().Be("/atomic:operations[0]"); - - Error 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.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_add_for_relationship_mismatch_between_ref_and_data() - { - // 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 = "add", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "playlists", - id = 88888888 - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Resource type mismatch between 'ref.relationship' and 'data[].type' element."); - error.Detail.Should().Be("Expected resource of type 'performers' in 'data[].type', instead of 'playlists'."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Can_add_with_empty_data_array() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Performers = _fakers.Performer.Generate(1); - - 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 = "performers" - }, - data = new object[0] - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Performers.Should().HaveCount(1); - trackInDatabase.Performers[0].Id.Should().Be(existingTrack.Performers[0].Id); - }); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs deleted file mode 100644 index f09d85cf36..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicRemoveFromToManyRelationshipTests.cs +++ /dev/null @@ -1,940 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Controllers; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.EntityFrameworkCore; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Updating.Relationships -{ - public sealed class AtomicRemoveFromToManyRelationshipTests - : IClassFixture, OperationsDbContext>> - { - private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new OperationsFakers(); - - public AtomicRemoveFromToManyRelationshipTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task Cannot_remove_from_HasOne_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); - - 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 = "ownedBy" - }, - data = new - { - type = "recordCompanies", - id = existingTrack.OwnedBy.StringId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Only to-many relationships can be targeted in 'remove' operations."); - error.Detail.Should().Be("Relationship 'ownedBy' must be a to-many relationship."); - } - - [Fact] - public async Task Can_remove_from_HasMany_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Performers = _fakers.Performer.Generate(3); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - id = existingTrack.Performers[0].StringId - } - } - }, - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - id = existingTrack.Performers[2].StringId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Performers.Should().HaveCount(1); - trackInDatabase.Performers[0].Id.Should().Be(existingTrack.Performers[1].Id); - - List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.Should().HaveCount(3); - }); - } - - [Fact] - public async Task Can_remove_from_HasManyThrough_relationship() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); - - existingPlaylist.PlaylistMusicTracks = new List - { - new PlaylistMusicTrack - { - MusicTrack = _fakers.MusicTrack.Generate() - }, - new PlaylistMusicTrack - { - MusicTrack = _fakers.MusicTrack.Generate() - }, - new PlaylistMusicTrack - { - MusicTrack = _fakers.MusicTrack.Generate() - } - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Playlists.Add(existingPlaylist); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = existingPlaylist.PlaylistMusicTracks[0].MusicTrack.StringId - } - } - }, - new - { - op = "remove", - @ref = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = existingPlaylist.PlaylistMusicTracks[2].MusicTrack.StringId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - Playlist playlistInDatabase = await dbContext.Playlists - .Include(playlist => playlist.PlaylistMusicTracks) - .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) - .FirstWithIdAsync(existingPlaylist.Id); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(1); - playlistInDatabase.PlaylistMusicTracks[0].MusicTrack.Id.Should().Be(existingPlaylist.PlaylistMusicTracks[1].MusicTrack.Id); - - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().HaveCount(3); - }); - } - - [Fact] - public async Task Cannot_remove_for_href_element() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - href = "/api/v1/musicTracks/1/relationships/performers" - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Usage of the 'href' element is not supported."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_remove_for_missing_type_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - id = 99999999, - relationship = "tracks" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.type' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_remove_for_unknown_type_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "doesNotExist", - id = 99999999, - relationship = "tracks" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); - error.Detail.Should().Be("Resource type 'doesNotExist' does not exist."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_remove_for_missing_ID_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - relationship = "performers" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_remove_for_unknown_ID_in_ref() - { - // 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 = "remove", - @ref = new - { - type = "recordCompanies", - id = 9999, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = existingTrack.StringId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 '9999' does not exist."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_remove_for_ID_and_local_ID_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - id = Guid.NewGuid().ToString(), - lid = "local-1", - relationship = "performers" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_remove_for_unknown_relationship_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "performers", - id = 99999999, - relationship = "doesNotExist" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The referenced relationship does not exist."); - error.Detail.Should().Be("Resource of type 'performers' does not contain a relationship named 'doesNotExist'."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_remove_for_null_data() - { - // 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 = "remove", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = (object)null - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected data[] element for to-many relationship."); - error.Detail.Should().Be("Expected data[] element for 'performers' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_remove_for_missing_type_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "playlists", - id = 99999999, - relationship = "tracks" - }, - data = new[] - { - new - { - id = Guid.NewGuid().ToString() - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data[].type' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_remove_for_unknown_type_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - id = Guid.NewGuid().ToString(), - relationship = "performers" - }, - data = new[] - { - new - { - type = "doesNotExist", - id = 99999999 - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); - error.Detail.Should().Be("Resource type 'doesNotExist' does not exist."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_remove_for_missing_ID_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - id = Guid.NewGuid().ToString(), - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers" - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data[].id' or 'data[].lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_remove_for_ID_and_local_ID_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - id = Guid.NewGuid().ToString(), - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - id = 99999999, - lid = "local-1" - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data[].id' or 'data[].lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_remove_for_unknown_IDs_in_data() - { - // Arrange - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - Guid[] trackIds = ArrayFactory.Create(Guid.NewGuid(), Guid.NewGuid()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.RecordCompanies.Add(existingCompany); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "recordCompanies", - id = existingCompany.StringId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = trackIds[0].ToString() - }, - new - { - type = "musicTracks", - id = trackIds[1].ToString() - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(2); - - Error 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.Pointer.Should().Be("/atomic:operations[0]"); - - Error 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.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_remove_for_relationship_mismatch_between_ref_and_data() - { - // 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 = "remove", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "playlists", - id = 88888888 - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Resource type mismatch between 'ref.relationship' and 'data[].type' element."); - error.Detail.Should().Be("Expected resource of type 'performers' in 'data[].type', instead of 'playlists'."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Can_remove_with_empty_data_array() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Performers = _fakers.Performer.Generate(1); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "remove", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new object[0] - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Performers.Should().HaveCount(1); - trackInDatabase.Performers[0].Id.Should().Be(existingTrack.Performers[0].Id); - }); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs deleted file mode 100644 index 17606bd01d..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicReplaceToManyRelationshipTests.cs +++ /dev/null @@ -1,1048 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Controllers; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.EntityFrameworkCore; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Updating.Relationships -{ - public sealed class AtomicReplaceToManyRelationshipTests - : IClassFixture, OperationsDbContext>> - { - private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new OperationsFakers(); - - public AtomicReplaceToManyRelationshipTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task Can_clear_HasMany_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Performers = _fakers.Performer.Generate(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new object[0] - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Performers.Should().BeEmpty(); - - List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.Should().HaveCount(2); - }); - } - - [Fact] - public async Task Can_clear_HasManyThrough_relationship() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); - - existingPlaylist.PlaylistMusicTracks = new List - { - new PlaylistMusicTrack - { - MusicTrack = _fakers.MusicTrack.Generate() - }, - new PlaylistMusicTrack - { - MusicTrack = _fakers.MusicTrack.Generate() - } - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Playlists.Add(existingPlaylist); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationship = "tracks" - }, - data = new object[0] - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - Playlist playlistInDatabase = await dbContext.Playlists - .Include(playlist => playlist.PlaylistMusicTracks) - .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) - .FirstWithIdAsync(existingPlaylist.Id); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - playlistInDatabase.PlaylistMusicTracks.Should().BeEmpty(); - - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().HaveCount(2); - }); - } - - [Fact] - public async Task Can_replace_HasMany_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Performers = _fakers.Performer.Generate(1); - - List existingPerformers = _fakers.Performer.Generate(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.Add(existingTrack); - dbContext.Performers.AddRange(existingPerformers); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - id = existingPerformers[0].StringId - }, - new - { - type = "performers", - id = existingPerformers[1].StringId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - - 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.Should().HaveCount(3); - }); - } - - [Fact] - public async Task Can_replace_HasManyThrough_relationship() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); - - existingPlaylist.PlaylistMusicTracks = new List - { - new PlaylistMusicTrack - { - MusicTrack = _fakers.MusicTrack.Generate() - } - }; - - List existingTracks = _fakers.MusicTrack.Generate(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Playlists.Add(existingPlaylist); - dbContext.MusicTracks.AddRange(existingTracks); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = existingTracks[0].StringId - }, - new - { - type = "musicTracks", - id = existingTracks[1].StringId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - Playlist playlistInDatabase = await dbContext.Playlists - .Include(playlist => playlist.PlaylistMusicTracks) - .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) - .FirstWithIdAsync(existingPlaylist.Id); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(2); - playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[0].Id); - playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[1].Id); - - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().HaveCount(3); - }); - } - - [Fact] - public async Task Cannot_replace_for_href_element() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - href = "/api/v1/musicTracks/1/relationships/performers" - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Usage of the 'href' element is not supported."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_replace_for_missing_type_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - id = 99999999, - relationship = "tracks" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.type' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_replace_for_unknown_type_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "doesNotExist", - id = 99999999, - relationship = "tracks" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); - error.Detail.Should().Be("Resource type 'doesNotExist' does not exist."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_replace_for_missing_ID_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - relationship = "performers" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_replace_for_unknown_ID_in_ref() - { - // 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", - @ref = new - { - type = "recordCompanies", - id = 9999, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = existingTrack.StringId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 '9999' does not exist."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_replace_for_incompatible_ID_in_ref() - { - // Arrange - string guid = Guid.NewGuid().ToString(); - - 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", - @ref = new - { - type = "recordCompanies", - id = guid, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = existingTrack.StringId - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); - error.Detail.Should().Be($"Failed to convert '{guid}' of type 'String' to type 'Int16'."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_replace_for_ID_and_local_ID_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = Guid.NewGuid().ToString(), - lid = "local-1", - relationship = "performers" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_replace_for_unknown_relationship_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "performers", - id = 99999999, - relationship = "doesNotExist" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The referenced relationship does not exist."); - error.Detail.Should().Be("Resource of type 'performers' does not contain a relationship named 'doesNotExist'."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_replace_for_null_data() - { - // 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", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = (object)null - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected data[] element for to-many relationship."); - error.Detail.Should().Be("Expected data[] element for 'performers' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_replace_for_missing_type_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "playlists", - id = 99999999, - relationship = "tracks" - }, - data = new[] - { - new - { - id = Guid.NewGuid().ToString() - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data[].type' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_replace_for_unknown_type_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = Guid.NewGuid().ToString(), - relationship = "performers" - }, - data = new[] - { - new - { - type = "doesNotExist", - id = 99999999 - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); - error.Detail.Should().Be("Resource type 'doesNotExist' does not exist."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_replace_for_missing_ID_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = Guid.NewGuid().ToString(), - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers" - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data[].id' or 'data[].lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_replace_for_ID_and_local_ID_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = Guid.NewGuid().ToString(), - relationship = "performers" - }, - data = new[] - { - new - { - type = "performers", - id = 99999999, - lid = "local-1" - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data[].id' or 'data[].lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_replace_for_unknown_IDs_in_data() - { - // Arrange - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - Guid[] trackIds = ArrayFactory.Create(Guid.NewGuid(), Guid.NewGuid()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.RecordCompanies.Add(existingCompany); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "recordCompanies", - id = existingCompany.StringId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = trackIds[0].ToString() - }, - new - { - type = "musicTracks", - id = trackIds[1].ToString() - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(2); - - Error 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.Pointer.Should().Be("/atomic:operations[0]"); - - Error 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.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_replace_for_incompatible_ID_in_data() - { - // Arrange - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.RecordCompanies.Add(existingCompany); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "recordCompanies", - id = existingCompany.StringId, - relationship = "tracks" - }, - data = new[] - { - new - { - type = "musicTracks", - id = "invalid-guid" - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); - error.Detail.Should().Be("Failed to convert 'invalid-guid' of type 'String' to type 'Guid'."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_replace_for_relationship_mismatch_between_ref_and_data() - { - // 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", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "performers" - }, - data = new[] - { - new - { - type = "playlists", - id = 88888888 - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Resource type mismatch between 'ref.relationship' and 'data[].type' element."); - error.Detail.Should().Be("Expected resource of type 'performers' in 'data[].type', instead of 'playlists'."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs deleted file mode 100644 index a8bec2eaae..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Relationships/AtomicUpdateToOneRelationshipTests.cs +++ /dev/null @@ -1,1228 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Controllers; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.EntityFrameworkCore; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Updating.Relationships -{ - public sealed class AtomicUpdateToOneRelationshipTests - : IClassFixture, OperationsDbContext>> - { - private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new OperationsFakers(); - - public AtomicUpdateToOneRelationshipTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task Can_clear_OneToOne_relationship_from_principal_side() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.Generate(); - existingLyric.Track = _fakers.MusicTrack.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Lyrics.Add(existingLyric); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "lyrics", - id = existingLyric.StringId, - relationship = "track" - }, - data = (object)null - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); - - lyricInDatabase.Track.Should().BeNull(); - - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().HaveCount(1); - }); - } - - [Fact] - public async Task Can_clear_OneToOne_relationship_from_dependent_side() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Lyric = _fakers.Lyric.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "lyric" - }, - data = (object)null - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Lyric.Should().BeNull(); - - List lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); - lyricsInDatabase.Should().HaveCount(1); - }); - } - - [Fact] - public async Task Can_clear_ManyToOne_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.Add(existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "ownedBy" - }, - data = (object)null - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.OwnedBy.Should().BeNull(); - - List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.Should().HaveCount(1); - }); - } - - [Fact] - public async Task Can_create_OneToOne_relationship_from_principal_side() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.Generate(); - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingLyric, existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "lyrics", - id = existingLyric.StringId, - relationship = "track" - }, - data = new - { - type = "musicTracks", - id = existingTrack.StringId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); - - lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); - }); - } - - [Fact] - public async Task Can_create_OneToOne_relationship_from_dependent_side() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - Lyric existingLyric = _fakers.Lyric.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingTrack, existingLyric); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "lyric" - }, - data = new - { - type = "lyrics", - id = existingLyric.StringId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); - }); - } - - [Fact] - public async Task Can_create_ManyToOne_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingTrack, existingCompany); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "ownedBy" - }, - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); - }); - } - - [Fact] - public async Task Can_replace_OneToOne_relationship_from_principal_side() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.Generate(); - existingLyric.Track = _fakers.MusicTrack.Generate(); - - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddRange(existingLyric, existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "lyrics", - id = existingLyric.StringId, - relationship = "track" - }, - data = new - { - type = "musicTracks", - id = existingTrack.StringId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); - - lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); - - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().HaveCount(2); - }); - } - - [Fact] - public async Task Can_replace_OneToOne_relationship_from_dependent_side() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Lyric = _fakers.Lyric.Generate(); - - Lyric existingLyric = _fakers.Lyric.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddRange(existingTrack, existingLyric); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "lyric" - }, - data = new - { - type = "lyrics", - id = existingLyric.StringId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); - - List lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); - lyricsInDatabase.Should().HaveCount(2); - }); - } - - [Fact] - public async Task Can_replace_ManyToOne_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); - - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddRange(existingTrack, existingCompany); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "ownedBy" - }, - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); - - List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.Should().HaveCount(2); - }); - } - - [Fact] - public async Task Cannot_create_for_href_element() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - href = "/api/v1/musicTracks/1/relationships/ownedBy" - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Usage of the 'href' element is not supported."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_for_missing_type_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - id = 99999999, - relationship = "track" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.type' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_for_unknown_type_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "doesNotExist", - id = 99999999, - relationship = "ownedBy" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); - error.Detail.Should().Be("Resource type 'doesNotExist' does not exist."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_for_missing_ID_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - relationship = "ownedBy" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_for_unknown_ID_in_ref() - { - // Arrange - string missingTrackId = Guid.NewGuid().ToString(); - - 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", - @ref = new - { - type = "musicTracks", - id = missingTrackId, - relationship = "lyric" - }, - data = new - { - type = "lyrics", - id = existingLyric.StringId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 '{missingTrackId}' does not exist."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_for_incompatible_ID_in_ref() - { - // 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", - @ref = new - { - type = "musicTracks", - id = "invalid-guid", - relationship = "lyric" - }, - data = new - { - type = "lyrics", - id = existingLyric.StringId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); - error.Detail.Should().Be("Failed to convert 'invalid-guid' of type 'String' to type 'Guid'."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_for_ID_and_local_ID_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = Guid.NewGuid().ToString(), - lid = "local-1", - relationship = "ownedBy" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_for_unknown_relationship_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "performers", - id = 99999999, - relationship = "doesNotExist" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The referenced relationship does not exist."); - error.Detail.Should().Be("Resource of type 'performers' does not contain a relationship named 'doesNotExist'."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_for_array_in_data() - { - // 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", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "lyric" - }, - data = new[] - { - new - { - type = "lyrics", - id = 99999999 - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected single data element for to-one relationship."); - error.Detail.Should().Be("Expected single data element for 'lyric' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_for_missing_type_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "lyrics", - id = 99999999, - relationship = "track" - }, - data = new - { - id = Guid.NewGuid().ToString() - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data.type' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_for_unknown_type_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = Guid.NewGuid().ToString(), - relationship = "lyric" - }, - data = new - { - type = "doesNotExist", - id = 99999999 - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); - error.Detail.Should().Be("Resource type 'doesNotExist' does not exist."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_for_missing_ID_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = Guid.NewGuid().ToString(), - relationship = "lyric" - }, - data = new - { - type = "lyrics" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data.id' or 'data.lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_for_ID_and_local_ID_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "musicTracks", - id = Guid.NewGuid().ToString(), - relationship = "lyric" - }, - data = new - { - type = "lyrics", - id = 99999999, - lid = "local-1" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data.id' or 'data.lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_for_unknown_ID_in_data() - { - // 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", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "lyric" - }, - data = new - { - type = "lyrics", - id = 99999999 - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 '99999999' in relationship 'lyric' does not exist."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_for_incompatible_ID_in_data() - { - // 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", - @ref = new - { - type = "lyrics", - id = existingLyric.StringId, - relationship = "track" - }, - data = new - { - type = "musicTracks", - id = "invalid-guid" - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); - error.Detail.Should().Be("Failed to convert 'invalid-guid' of type 'String' to type 'Guid'."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_for_relationship_mismatch_between_ref_and_data() - { - // 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", - @ref = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationship = "lyric" - }, - data = new - { - type = "playlists", - id = 99999999 - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Resource type mismatch between 'ref.relationship' and 'data.type' element."); - error.Detail.Should().Be("Expected resource of type 'lyrics' in 'data.type', instead of 'playlists'."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs deleted file mode 100644 index 004b3e777f..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicReplaceToManyRelationshipTests.cs +++ /dev/null @@ -1,717 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Controllers; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.EntityFrameworkCore; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Updating.Resources -{ - public sealed class AtomicReplaceToManyRelationshipTests - : IClassFixture, OperationsDbContext>> - { - private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new OperationsFakers(); - - public AtomicReplaceToManyRelationshipTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task Can_clear_HasMany_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Performers = _fakers.Performer.Generate(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - 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 - { - performers = new - { - data = new object[0] - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Performers.Should().BeEmpty(); - - List performersInDatabase = await dbContext.Performers.ToListAsync(); - performersInDatabase.Should().HaveCount(2); - }); - } - - [Fact] - public async Task Can_clear_HasManyThrough_relationship() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); - - existingPlaylist.PlaylistMusicTracks = new List - { - new PlaylistMusicTrack - { - MusicTrack = _fakers.MusicTrack.Generate() - }, - new PlaylistMusicTrack - { - MusicTrack = _fakers.MusicTrack.Generate() - } - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Playlists.Add(existingPlaylist); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationships = new - { - tracks = new - { - data = new object[0] - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - Playlist playlistInDatabase = await dbContext.Playlists - .Include(playlist => playlist.PlaylistMusicTracks) - .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) - .FirstWithIdAsync(existingPlaylist.Id); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - playlistInDatabase.PlaylistMusicTracks.Should().BeEmpty(); - - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().HaveCount(2); - }); - } - - [Fact] - public async Task Can_replace_HasMany_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Performers = _fakers.Performer.Generate(1); - - List existingPerformers = _fakers.Performer.Generate(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.Add(existingTrack); - dbContext.Performers.AddRange(existingPerformers); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new - { - performers = new - { - data = new[] - { - new - { - type = "performers", - id = existingPerformers[0].StringId - }, - new - { - type = "performers", - id = existingPerformers[1].StringId - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Performers).FirstWithIdAsync(existingTrack.Id); - - 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.Should().HaveCount(3); - }); - } - - [Fact] - public async Task Can_replace_HasManyThrough_relationship() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); - - existingPlaylist.PlaylistMusicTracks = new List - { - new PlaylistMusicTrack - { - MusicTrack = _fakers.MusicTrack.Generate() - } - }; - - List existingTracks = _fakers.MusicTrack.Generate(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Playlists.Add(existingPlaylist); - dbContext.MusicTracks.AddRange(existingTracks); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "playlists", - id = existingPlaylist.StringId, - relationships = new - { - tracks = new - { - data = new[] - { - new - { - type = "musicTracks", - id = existingTracks[0].StringId - }, - new - { - type = "musicTracks", - id = existingTracks[1].StringId - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - Playlist playlistInDatabase = await dbContext.Playlists - .Include(playlist => playlist.PlaylistMusicTracks) - .ThenInclude(playlistMusicTrack => playlistMusicTrack.MusicTrack) - .FirstWithIdAsync(existingPlaylist.Id); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - playlistInDatabase.PlaylistMusicTracks.Should().HaveCount(2); - playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[0].Id); - playlistInDatabase.PlaylistMusicTracks.Should().ContainSingle(playlistMusicTrack => playlistMusicTrack.MusicTrack.Id == existingTracks[1].Id); - - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().HaveCount(3); - }); - } - - [Fact] - public async Task Cannot_replace_for_null_relationship_data() - { - // 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, - relationships = new - { - performers = new - { - data = (object)null - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected data[] element for to-many relationship."); - error.Detail.Should().Be("Expected data[] element for 'performers' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_replace_for_missing_type_in_relationship_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "playlists", - id = 99999999, - relationships = new - { - tracks = new - { - data = new[] - { - new - { - id = Guid.NewGuid().ToString() - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); - error.Detail.Should().Be("Expected 'type' element in 'tracks' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_replace_for_unknown_type_in_relationship_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = Guid.NewGuid().ToString(), - relationships = new - { - performers = new - { - data = new[] - { - new - { - type = "doesNotExist", - id = 99999999 - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); - error.Detail.Should().Be("Resource type 'doesNotExist' does not exist."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_replace_for_missing_ID_in_relationship_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = Guid.NewGuid().ToString(), - relationships = new - { - performers = new - { - data = new[] - { - new - { - type = "performers" - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body must include 'id' or 'lid' element."); - error.Detail.Should().Be("Expected 'id' or 'lid' element in 'performers' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_replace_for_ID_and_local_ID_relationship_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = Guid.NewGuid().ToString(), - relationships = new - { - performers = new - { - data = new[] - { - new - { - type = "performers", - id = 99999999, - lid = "local-1" - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body must include 'id' or 'lid' element."); - error.Detail.Should().Be("Expected 'id' or 'lid' element in 'performers' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_replace_for_unknown_IDs_in_relationship_data() - { - // Arrange - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - Guid[] trackIds = ArrayFactory.Create(Guid.NewGuid(), Guid.NewGuid()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.RecordCompanies.Add(existingCompany); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "recordCompanies", - id = existingCompany.StringId, - relationships = new - { - tracks = new - { - data = new[] - { - new - { - type = "musicTracks", - id = trackIds[0].ToString() - }, - new - { - type = "musicTracks", - id = trackIds[1].ToString() - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(2); - - Error 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.Pointer.Should().Be("/atomic:operations[0]"); - - Error 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.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_for_relationship_mismatch() - { - // 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, - relationships = new - { - performers = new - { - data = new[] - { - new - { - type = "playlists", - id = 88888888 - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Relationship contains incompatible resource type."); - error.Detail.Should().Be("Relationship 'performers' contains incompatible resource type 'playlists'."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs deleted file mode 100644 index 3a69931019..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs +++ /dev/null @@ -1,1606 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using FluentAssertions.Extensions; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Controllers; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.EntityFrameworkCore; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Updating.Resources -{ - public sealed class AtomicUpdateResourceTests : IClassFixture, OperationsDbContext>> - { - private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new OperationsFakers(); - - public AtomicUpdateResourceTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - - // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. - testContext.UseController(); - } - - [Fact] - 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(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.MusicTracks.AddRange(existingTracks); - await dbContext.SaveChangesAsync(); - }); - - var operationElements = new List(elementCount); - - for (int index = 0; index < elementCount; index++) - { - operationElements.Add(new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTracks[index].StringId, - attributes = new - { - title = newTrackTitles[index] - } - } - }); - } - - var requestBody = new - { - atomic__operations = operationElements - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - - tracksInDatabase.Should().HaveCount(elementCount); - - for (int index = 0; index < elementCount; index++) - { - MusicTrack trackInDatabase = tracksInDatabase.Single(musicTrack => musicTrack.Id == existingTracks[index].Id); - - trackInDatabase.Title.Should().Be(newTrackTitles[index]); - trackInDatabase.Genre.Should().Be(existingTracks[index].Genre); - } - }); - } - - [Fact] - public async Task Can_update_resource_without_attributes_or_relationships() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.OwnedBy = _fakers.RecordCompany.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 - { - }, - relationships = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Title.Should().Be(existingTrack.Title); - trackInDatabase.Genre.Should().Be(existingTrack.Genre); - - trackInDatabase.OwnedBy.Should().NotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingTrack.OwnedBy.Id); - }); - } - - [Fact] - public async Task Can_update_resource_with_unknown_attribute() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - string newTitle = _fakers.MusicTrack.Generate().Title; - - 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 - { - title = newTitle, - doesNotExist = "Ignored" - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Title.Should().Be(newTitle); - }); - } - - [Fact] - public async Task Can_update_resource_with_unknown_relationship() - { - // 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, - relationships = new - { - doesNotExist = new - { - data = new - { - type = "doesNotExist", - id = 12345678 - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - } - - [Fact] - public async Task Can_partially_update_resource_without_side_effects() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); - - string newGenre = _fakers.MusicTrack.Generate().Genre; - - 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 - { - genre = newGenre - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Title.Should().Be(existingTrack.Title); - trackInDatabase.LengthInSeconds.Should().BeApproximately(existingTrack.LengthInSeconds); - trackInDatabase.Genre.Should().Be(newGenre); - trackInDatabase.ReleasedAt.Should().BeCloseTo(existingTrack.ReleasedAt); - - trackInDatabase.OwnedBy.Should().NotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingTrack.OwnedBy.Id); - }); - } - - [Fact] - public async Task Can_completely_update_resource_without_side_effects() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); - - string newTitle = _fakers.MusicTrack.Generate().Title; - decimal? newLengthInSeconds = _fakers.MusicTrack.Generate().LengthInSeconds; - string newGenre = _fakers.MusicTrack.Generate().Genre; - DateTimeOffset newReleasedAt = _fakers.MusicTrack.Generate().ReleasedAt; - - 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 - { - title = newTitle, - lengthInSeconds = newLengthInSeconds, - genre = newGenre, - releasedAt = newReleasedAt - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Title.Should().Be(newTitle); - trackInDatabase.LengthInSeconds.Should().BeApproximately(newLengthInSeconds); - trackInDatabase.Genre.Should().Be(newGenre); - trackInDatabase.ReleasedAt.Should().BeCloseTo(newReleasedAt); - - trackInDatabase.OwnedBy.Should().NotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingTrack.OwnedBy.Id); - }); - } - - [Fact] - public async Task Can_update_resource_with_side_effects() - { - // Arrange - TextLanguage existingLanguage = _fakers.TextLanguage.Generate(); - string newIsoCode = _fakers.TextLanguage.Generate().IsoCode; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TextLanguages.Add(existingLanguage); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "textLanguages", - id = existingLanguage.StringId, - attributes = new - { - isoCode = newIsoCode - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].SingleData.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Type.Should().Be("textLanguages"); - responseDocument.Results[0].SingleData.Attributes["isoCode"].Should().Be(newIsoCode); - responseDocument.Results[0].SingleData.Attributes.Should().NotContainKey("concurrencyToken"); - responseDocument.Results[0].SingleData.Relationships.Should().NotBeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - TextLanguage languageInDatabase = await dbContext.TextLanguages.FirstWithIdAsync(existingLanguage.Id); - - languageInDatabase.IsoCode.Should().Be(newIsoCode); - }); - } - - [Fact] - 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); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.TextLanguages.Add(existingLanguage); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "textLanguages", - id = existingLanguage.StringId - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = - await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Results.Should().HaveCount(1); - responseDocument.Results[0].SingleData.Should().NotBeNull(); - responseDocument.Results[0].SingleData.Relationships.Should().NotBeEmpty(); - responseDocument.Results[0].SingleData.Relationships.Values.Should().OnlyContain(relationshipEntry => relationshipEntry.Data == null); - } - - [Fact] - public async Task Cannot_update_resource_for_href_element() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - href = "/api/v1/musicTracks/1" - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Usage of the 'href' element is not supported."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Can_update_resource_for_ref_element() - { - // Arrange - Performer existingPerformer = _fakers.Performer.Generate(); - string newArtistName = _fakers.Performer.Generate().ArtistName; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Performers.Add(existingPerformer); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "performers", - id = existingPerformer.StringId - }, - data = new - { - type = "performers", - id = existingPerformer.StringId, - attributes = new - { - artistName = newArtistName - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Performer performerInDatabase = await dbContext.Performers.FirstWithIdAsync(existingPerformer.Id); - - performerInDatabase.ArtistName.Should().Be(newArtistName); - performerInDatabase.BornAt.Should().BeCloseTo(existingPerformer.BornAt); - }); - } - - [Fact] - public async Task Cannot_update_resource_for_missing_type_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - id = 12345678 - }, - data = new - { - type = "performers", - id = 12345678, - attributes = new - { - }, - relationships = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.type' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_update_resource_for_missing_ID_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "performers" - }, - data = new - { - type = "performers", - id = 12345678, - attributes = new - { - }, - relationships = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_update_resource_for_ID_and_local_ID_in_ref() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "performers", - id = 12345678, - lid = "local-1" - }, - data = new - { - type = "performers", - id = 12345678, - attributes = new - { - }, - relationships = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'ref.id' or 'ref.lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_update_resource_for_missing_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update" - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_update_resource_for_missing_type_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - id = 12345678, - attributes = new - { - }, - relationships = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data.type' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_update_resource_for_missing_ID_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "performers", - attributes = new - { - }, - relationships = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data.id' or 'data.lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_update_resource_for_ID_and_local_ID_in_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "performers", - id = 12345678, - lid = "local-1", - attributes = new - { - }, - relationships = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: The 'data.id' or 'data.lid' element is required."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_update_resource_for_array_in_data() - { - // Arrange - Performer existingPerformer = _fakers.Performer.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Performers.Add(existingPerformer); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new[] - { - new - { - type = "performers", - id = existingPerformer.StringId, - attributes = new - { - artistName = existingPerformer.ArtistName - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected single data element for create/update resource operation."); - error.Detail.Should().BeNull(); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_update_on_resource_type_mismatch_between_ref_and_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "performers", - id = 12345678 - }, - data = new - { - type = "playlists", - id = 12345678, - attributes = new - { - }, - relationships = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Resource type mismatch between 'ref.type' and 'data.type' element."); - error.Detail.Should().Be("Expected resource of type 'performers' in 'data.type', instead of 'playlists'."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_update_on_resource_ID_mismatch_between_ref_and_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "performers", - id = 12345678 - }, - data = new - { - type = "performers", - id = 87654321, - attributes = new - { - }, - relationships = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Resource ID mismatch between 'ref.id' and 'data.id' element."); - error.Detail.Should().Be("Expected resource with ID '12345678' in 'data.id', instead of '87654321'."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_update_on_resource_local_ID_mismatch_between_ref_and_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "performers", - lid = "local-1" - }, - data = new - { - type = "performers", - lid = "local-2", - attributes = new - { - }, - relationships = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Resource local ID mismatch between 'ref.lid' and 'data.lid' element."); - error.Detail.Should().Be("Expected resource with local ID 'local-1' in 'data.lid', instead of 'local-2'."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_update_on_mixture_of_ID_and_local_ID_between_ref_and_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "performers", - id = "12345678" - }, - data = new - { - type = "performers", - lid = "local-1", - attributes = new - { - }, - relationships = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Resource identity mismatch between 'ref.id' and 'data.lid' element."); - error.Detail.Should().Be("Expected resource with ID '12345678' in 'data.id', instead of 'local-1' in 'data.lid'."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_update_on_mixture_of_local_ID_and_ID_between_ref_and_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "performers", - lid = "local-1" - }, - data = new - { - type = "performers", - id = "12345678", - attributes = new - { - }, - relationships = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Resource identity mismatch between 'ref.lid' and 'data.id' element."); - error.Detail.Should().Be("Expected resource with local ID 'local-1' in 'data.lid', instead of '12345678' in 'data.id'."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_update_resource_for_unknown_type() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "doesNotExist", - id = 12345678, - attributes = new - { - }, - relationships = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); - error.Detail.Should().Be("Resource type 'doesNotExist' does not exist."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_update_resource_for_unknown_ID() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "performers", - id = 99999999, - attributes = new - { - }, - relationships = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 '99999999' does not exist."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_update_resource_for_incompatible_ID() - { - // Arrange - string guid = Guid.NewGuid().ToString(); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - @ref = new - { - type = "performers", - id = guid - }, - data = new - { - type = "performers", - id = guid, - attributes = new - { - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); - error.Detail.Should().Be($"Failed to convert '{guid}' of type 'String' to type 'Int32'."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [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, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Changing the value of the requested attribute is not allowed."); - error.Detail.Should().Be("Changing the value of 'createdAt' is not allowed."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_update_resource_with_readonly_attribute() - { - // Arrange - Playlist existingPlaylist = _fakers.Playlist.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Playlists.Add(existingPlaylist); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "playlists", - id = existingPlaylist.StringId, - attributes = new - { - isArchived = true - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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' is read-only."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_change_ID_of_existing_resource() - { - // Arrange - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.RecordCompanies.Add(existingCompany); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "recordCompanies", - id = existingCompany.StringId, - attributes = new - { - id = (existingCompany.Id + 1).ToString() - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_update_resource_with_incompatible_attribute_value() - { - // Arrange - Performer existingPerformer = _fakers.Performer.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Performers.Add(existingPerformer); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "performers", - id = existingPerformer.StringId, - attributes = new - { - bornAt = "not-a-valid-time" - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); - error.Detail.Should().StartWith("Failed to convert 'not-a-valid-time' of type 'String' to type 'DateTimeOffset'. - Request body:"); - error.Source.Pointer.Should().BeNull(); - } - - [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); - - string newGenre = _fakers.MusicTrack.Generate().Genre; - - Lyric existingLyric = _fakers.Lyric.Generate(); - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - Performer existingPerformer = _fakers.Performer.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(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.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - MusicTrack trackInDatabase = await dbContext.MusicTracks - .Include(musicTrack => musicTrack.Lyric) - .Include(musicTrack => musicTrack.OwnedBy) - .Include(musicTrack => musicTrack.Performers) - .FirstWithIdAsync(existingTrack.Id); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - trackInDatabase.Title.Should().Be(existingTrack.Title); - trackInDatabase.Genre.Should().Be(newGenre); - - trackInDatabase.Lyric.Should().NotBeNull(); - trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); - - trackInDatabase.OwnedBy.Should().NotBeNull(); - trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); - - trackInDatabase.Performers.Should().HaveCount(1); - trackInDatabase.Performers[0].Id.Should().Be(existingPerformer.Id); - }); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs deleted file mode 100644 index 0460f5e2e5..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateToOneRelationshipTests.cs +++ /dev/null @@ -1,931 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Controllers; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.EntityFrameworkCore; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations.Updating.Resources -{ - public sealed class AtomicUpdateToOneRelationshipTests - : IClassFixture, OperationsDbContext>> - { - private readonly ExampleIntegrationTestContext, OperationsDbContext> _testContext; - private readonly OperationsFakers _fakers = new OperationsFakers(); - - public AtomicUpdateToOneRelationshipTests(ExampleIntegrationTestContext, OperationsDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task Can_clear_OneToOne_relationship_from_principal_side() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.Generate(); - existingLyric.Track = _fakers.MusicTrack.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - 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 - { - track = new - { - data = (object)null - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); - - lyricInDatabase.Track.Should().BeNull(); - - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().HaveCount(1); - }); - } - - [Fact] - public async Task Can_clear_OneToOne_relationship_from_dependent_side() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Lyric = _fakers.Lyric.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - 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 - { - lyric = new - { - data = (object)null - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Lyric.Should().BeNull(); - - List lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); - lyricsInDatabase.Should().HaveCount(1); - }); - } - - [Fact] - public async Task Can_clear_ManyToOne_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - 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 - { - ownedBy = new - { - data = (object)null - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.OwnedBy.Should().BeNull(); - - List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.Should().HaveCount(1); - }); - } - - [Fact] - public async Task Can_create_OneToOne_relationship_from_principal_side() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.Generate(); - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingLyric, existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "lyrics", - id = existingLyric.StringId, - relationships = new - { - track = new - { - data = new - { - type = "musicTracks", - id = existingTrack.StringId - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); - - lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); - }); - } - - [Fact] - public async Task Can_create_OneToOne_relationship_from_dependent_side() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - Lyric existingLyric = _fakers.Lyric.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingTrack, existingLyric); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new - { - lyric = new - { - data = new - { - type = "lyrics", - id = existingLyric.StringId - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); - }); - } - - [Fact] - public async Task Can_create_ManyToOne_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingTrack, existingCompany); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new - { - ownedBy = new - { - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); - }); - } - - [Fact] - public async Task Can_replace_OneToOne_relationship_from_principal_side() - { - // Arrange - Lyric existingLyric = _fakers.Lyric.Generate(); - existingLyric.Track = _fakers.MusicTrack.Generate(); - - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddRange(existingLyric, existingTrack); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "lyrics", - id = existingLyric.StringId, - relationships = new - { - track = new - { - data = new - { - type = "musicTracks", - id = existingTrack.StringId - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Lyric lyricInDatabase = await dbContext.Lyrics.Include(lyric => lyric.Track).FirstWithIdAsync(existingLyric.Id); - - lyricInDatabase.Track.Id.Should().Be(existingTrack.Id); - - List tracksInDatabase = await dbContext.MusicTracks.ToListAsync(); - tracksInDatabase.Should().HaveCount(2); - }); - } - - [Fact] - public async Task Can_replace_OneToOne_relationship_from_dependent_side() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.Lyric = _fakers.Lyric.Generate(); - - Lyric existingLyric = _fakers.Lyric.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddRange(existingTrack, existingLyric); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new - { - lyric = new - { - data = new - { - type = "lyrics", - id = existingLyric.StringId - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.Lyric).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.Lyric.Id.Should().Be(existingLyric.Id); - - List lyricsInDatabase = await dbContext.Lyrics.ToListAsync(); - lyricsInDatabase.Should().HaveCount(2); - }); - } - - [Fact] - public async Task Can_replace_ManyToOne_relationship() - { - // Arrange - MusicTrack existingTrack = _fakers.MusicTrack.Generate(); - existingTrack.OwnedBy = _fakers.RecordCompany.Generate(); - - RecordCompany existingCompany = _fakers.RecordCompany.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddRange(existingTrack, existingCompany); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = existingTrack.StringId, - relationships = new - { - ownedBy = new - { - data = new - { - type = "recordCompanies", - id = existingCompany.StringId - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - MusicTrack trackInDatabase = await dbContext.MusicTracks.Include(musicTrack => musicTrack.OwnedBy).FirstWithIdAsync(existingTrack.Id); - - trackInDatabase.OwnedBy.Id.Should().Be(existingCompany.Id); - - List companiesInDatabase = await dbContext.RecordCompanies.ToListAsync(); - companiesInDatabase.Should().HaveCount(2); - }); - } - - [Fact] - public async Task Cannot_create_for_array_in_relationship_data() - { - // 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, - relationships = new - { - lyric = new - { - data = new[] - { - new - { - type = "lyrics", - id = 99999999 - } - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Expected single data element for to-one relationship."); - error.Detail.Should().Be("Expected single data element for 'lyric' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_for_missing_type_in_relationship_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "lyrics", - id = 99999999, - relationships = new - { - track = new - { - data = new - { - id = Guid.NewGuid().ToString() - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body must include 'type' element."); - error.Detail.Should().Be("Expected 'type' element in 'track' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_for_unknown_type_in_relationship_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = Guid.NewGuid().ToString(), - relationships = new - { - lyric = new - { - data = new - { - type = "doesNotExist", - id = 99999999 - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body includes unknown resource type."); - error.Detail.Should().Be("Resource type 'doesNotExist' does not exist."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_for_missing_ID_in_relationship_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = Guid.NewGuid().ToString(), - relationships = new - { - lyric = new - { - data = new - { - type = "lyrics" - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body must include 'id' or 'lid' element."); - error.Detail.Should().Be("Expected 'id' or 'lid' element in 'lyric' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_for_ID_and_local_ID_in_relationship_data() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "update", - data = new - { - type = "musicTracks", - id = Guid.NewGuid().ToString(), - relationships = new - { - lyric = new - { - data = new - { - type = "lyrics", - id = 99999999, - lid = "local-1" - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Request body must include 'id' or 'lid' element."); - error.Detail.Should().Be("Expected 'id' or 'lid' element in 'lyric' relationship."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_for_unknown_ID_in_relationship_data() - { - // 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, - relationships = new - { - lyric = new - { - data = new - { - type = "lyrics", - id = 99999999 - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 '99999999' in relationship 'lyric' does not exist."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - - [Fact] - public async Task Cannot_create_for_relationship_mismatch() - { - // 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, - relationships = new - { - lyric = new - { - data = new - { - type = "playlists", - id = 99999999 - } - } - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body: Relationship contains incompatible resource type."); - error.Detail.Should().Be("Relationship 'lyric' contains incompatible resource type 'playlists'."); - error.Source.Pointer.Should().Be("/atomic:operations[0]"); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Car.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Car.cs deleted file mode 100644 index 5263e4e362..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Car.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using System.ComponentModel.DataAnnotations.Schema; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Car : Identifiable - { - [NotMapped] - public override string Id - { - get => $"{RegionId}:{LicensePlate}"; - set - { - string[] elements = value.Split(':'); - - if (elements.Length == 2) - { - if (int.TryParse(elements[0], out int regionId)) - { - RegionId = regionId; - LicensePlate = elements[1]; - } - } - else - { - throw new InvalidOperationException($"Failed to convert ID '{value}'."); - } - } - } - - [Attr] - public string LicensePlate { get; set; } - - [Attr] - public long RegionId { get; set; } - - [HasOne] - public Engine Engine { get; set; } - - [HasOne] - public Dealership Dealership { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs deleted file mode 100644 index f25b890546..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarExpressionRewriter.cs +++ /dev/null @@ -1,167 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using JsonApiDotNetCore; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys -{ - /// - /// Rewrites an expression tree, updating all references to with the combination of and - /// . - /// - /// - /// This enables queries to use , which is not mapped in the database. - /// - internal sealed class CarExpressionRewriter : QueryExpressionRewriter - { - private readonly AttrAttribute _regionIdAttribute; - private readonly AttrAttribute _licensePlateAttribute; - - public CarExpressionRewriter(IResourceContextProvider resourceContextProvider) - { - ResourceContext carResourceContext = resourceContextProvider.GetResourceContext(); - - _regionIdAttribute = carResourceContext.Attributes.Single(attribute => attribute.Property.Name == nameof(Car.RegionId)); - - _licensePlateAttribute = carResourceContext.Attributes.Single(attribute => attribute.Property.Name == nameof(Car.LicensePlate)); - } - - public override QueryExpression VisitComparison(ComparisonExpression expression, object argument) - { - if (expression.Left is ResourceFieldChainExpression leftChain && expression.Right is LiteralConstantExpression rightConstant) - { - PropertyInfo leftProperty = leftChain.Fields.Last().Property; - - if (IsCarId(leftProperty)) - { - if (expression.Operator != ComparisonOperator.Equals) - { - throw new NotSupportedException("Only equality comparisons are possible on Car IDs."); - } - - return RewriteFilterOnCarStringIds(leftChain, rightConstant.Value.AsEnumerable()); - } - } - - return base.VisitComparison(expression, argument); - } - - public override QueryExpression VisitEqualsAnyOf(EqualsAnyOfExpression expression, object argument) - { - PropertyInfo property = expression.TargetAttribute.Fields.Last().Property; - - if (IsCarId(property)) - { - string[] carStringIds = expression.Constants.Select(constant => constant.Value).ToArray(); - return RewriteFilterOnCarStringIds(expression.TargetAttribute, carStringIds); - } - - return base.VisitEqualsAnyOf(expression, argument); - } - - public override QueryExpression VisitMatchText(MatchTextExpression expression, object argument) - { - PropertyInfo property = expression.TargetAttribute.Fields.Last().Property; - - if (IsCarId(property)) - { - throw new NotSupportedException("Partial text matching on Car IDs is not possible."); - } - - return base.VisitMatchText(expression, argument); - } - - private static bool IsCarId(PropertyInfo property) - { - return property.Name == nameof(Identifiable.Id) && property.DeclaringType == typeof(Car); - } - - private QueryExpression RewriteFilterOnCarStringIds(ResourceFieldChainExpression existingCarIdChain, IEnumerable carStringIds) - { - var outerTerms = new List(); - - foreach (string carStringId in carStringIds) - { - var tempCar = new Car - { - StringId = carStringId - }; - - FilterExpression keyComparison = CreateEqualityComparisonOnCompositeKey(existingCarIdChain, tempCar.RegionId, tempCar.LicensePlate); - outerTerms.Add(keyComparison); - } - - return outerTerms.Count == 1 ? outerTerms[0] : new LogicalExpression(LogicalOperator.Or, outerTerms); - } - - private FilterExpression CreateEqualityComparisonOnCompositeKey(ResourceFieldChainExpression existingCarIdChain, long regionIdValue, - string licensePlateValue) - { - ResourceFieldChainExpression regionIdChain = ReplaceLastAttributeInChain(existingCarIdChain, _regionIdAttribute); - - var regionIdComparison = new ComparisonExpression(ComparisonOperator.Equals, regionIdChain, - new LiteralConstantExpression(regionIdValue.ToString())); - - ResourceFieldChainExpression licensePlateChain = ReplaceLastAttributeInChain(existingCarIdChain, _licensePlateAttribute); - - var licensePlateComparison = new ComparisonExpression(ComparisonOperator.Equals, licensePlateChain, - new LiteralConstantExpression(licensePlateValue)); - - return new LogicalExpression(LogicalOperator.And, new[] - { - regionIdComparison, - licensePlateComparison - }); - } - - public override QueryExpression VisitSort(SortExpression expression, object argument) - { - var newSortElements = new List(); - - foreach (SortElementExpression sortElement in expression.Elements) - { - if (IsSortOnCarId(sortElement)) - { - ResourceFieldChainExpression regionIdSort = ReplaceLastAttributeInChain(sortElement.TargetAttribute, _regionIdAttribute); - newSortElements.Add(new SortElementExpression(regionIdSort, sortElement.IsAscending)); - - ResourceFieldChainExpression licensePlateSort = ReplaceLastAttributeInChain(sortElement.TargetAttribute, _licensePlateAttribute); - newSortElements.Add(new SortElementExpression(licensePlateSort, sortElement.IsAscending)); - } - else - { - newSortElements.Add(sortElement); - } - } - - return new SortExpression(newSortElements); - } - - private static bool IsSortOnCarId(SortElementExpression sortElement) - { - if (sortElement.TargetAttribute != null) - { - PropertyInfo property = sortElement.TargetAttribute.Fields.Last().Property; - - if (IsCarId(property)) - { - return true; - } - } - - return false; - } - - private static ResourceFieldChainExpression ReplaceLastAttributeInChain(ResourceFieldChainExpression resourceFieldChain, AttrAttribute attribute) - { - List fields = resourceFieldChain.Fields.ToList(); - fields[^1] = attribute; - return new ResourceFieldChainExpression(fields); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarRepository.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarRepository.cs deleted file mode 100644 index 8142d8d378..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarRepository.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Queries.Expressions; -using JsonApiDotNetCore.Repositories; -using JsonApiDotNetCore.Resources; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys -{ - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class CarRepository : EntityFrameworkCoreRepository - { - private readonly IResourceGraph _resourceGraph; - - public CarRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, IResourceFactory resourceFactory, - IEnumerable constraintProviders, ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory) - { - _resourceGraph = resourceGraph; - } - - protected override IQueryable ApplyQueryLayer(QueryLayer layer) - { - RecursiveRewriteFilterInLayer(layer); - - return base.ApplyQueryLayer(layer); - } - - private void RecursiveRewriteFilterInLayer(QueryLayer queryLayer) - { - if (queryLayer.Filter != null) - { - var writer = new CarExpressionRewriter(_resourceGraph); - queryLayer.Filter = (FilterExpression)writer.Visit(queryLayer.Filter, null); - } - - if (queryLayer.Sort != null) - { - var writer = new CarExpressionRewriter(_resourceGraph); - queryLayer.Sort = (SortExpression)writer.Visit(queryLayer.Sort, null); - } - - if (queryLayer.Projection != null) - { - foreach (QueryLayer nextLayer in queryLayer.Projection.Values.Where(layer => layer != null)) - { - RecursiveRewriteFilterInLayer(nextLayer); - } - } - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarsController.cs deleted file mode 100644 index 982f72d625..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CarsController.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys -{ - public sealed class CarsController : JsonApiController - { - public CarsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs deleted file mode 100644 index 13609a17ec..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeDbContext.cs +++ /dev/null @@ -1,39 +0,0 @@ -using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore; - -// @formatter:wrap_chained_method_calls chop_always - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class CompositeDbContext : DbContext - { - public DbSet Cars { get; set; } - public DbSet Engines { get; set; } - public DbSet Dealerships { get; set; } - - public CompositeDbContext(DbContextOptions options) - : base(options) - { - } - - protected override void OnModelCreating(ModelBuilder builder) - { - builder.Entity() - .HasKey(car => new - { - car.RegionId, - car.LicensePlate - }); - - builder.Entity() - .HasOne(engine => engine.Car) - .WithOne(car => car.Engine) - .HasForeignKey(); - - builder.Entity() - .HasMany(dealership => dealership.Inventory) - .WithOne(car => car.Dealership); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs deleted file mode 100644 index c541f86a39..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/CompositeKeyTests.cs +++ /dev/null @@ -1,579 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys -{ - public sealed class CompositeKeyTests : IClassFixture, CompositeDbContext>> - { - private readonly ExampleIntegrationTestContext, CompositeDbContext> _testContext; - - public CompositeKeyTests(ExampleIntegrationTestContext, CompositeDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - testContext.UseController(); - testContext.UseController(); - - testContext.ConfigureServicesAfterStartup(services => - { - services.AddResourceRepository(); - }); - - var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); - options.AllowClientGeneratedIds = true; - } - - [Fact] - public async Task Can_filter_on_ID_in_primary_resources() - { - // Arrange - var car = new Car - { - RegionId = 123, - LicensePlate = "AA-BB-11" - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Cars.Add(car); - await dbContext.SaveChangesAsync(); - }); - - const string route = "/cars?filter=any(id,'123:AA-BB-11','999:XX-YY-22')"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Id.Should().Be(car.StringId); - } - - [Fact] - public async Task Can_get_primary_resource_by_ID() - { - // Arrange - var car = new Car - { - RegionId = 123, - LicensePlate = "AA-BB-11" - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Cars.Add(car); - await dbContext.SaveChangesAsync(); - }); - - string route = "/cars/" + car.StringId; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Id.Should().Be(car.StringId); - } - - [Fact] - public async Task Can_sort_on_ID() - { - // Arrange - var car = new Car - { - RegionId = 123, - LicensePlate = "AA-BB-11" - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Cars.Add(car); - await dbContext.SaveChangesAsync(); - }); - - const string route = "/cars?sort=id"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Id.Should().Be(car.StringId); - } - - [Fact] - public async Task Can_select_ID() - { - // Arrange - var car = new Car - { - RegionId = 123, - LicensePlate = "AA-BB-11" - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Cars.Add(car); - await dbContext.SaveChangesAsync(); - }); - - const string route = "/cars?fields[cars]=id"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Id.Should().Be(car.StringId); - } - - [Fact] - public async Task Can_create_resource() - { - // Arrange - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - }); - - var requestBody = new - { - data = new - { - type = "cars", - attributes = new - { - regionId = 123, - licensePlate = "AA-BB-11" - } - } - }; - - const string route = "/cars"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Car carInDatabase = await dbContext.Cars.FirstOrDefaultAsync(car => car.RegionId == 123 && car.LicensePlate == "AA-BB-11"); - - carInDatabase.Should().NotBeNull(); - carInDatabase.Id.Should().Be("123:AA-BB-11"); - }); - } - - [Fact] - public async Task Can_create_OneToOne_relationship() - { - // Arrange - var existingCar = new Car - { - RegionId = 123, - LicensePlate = "AA-BB-11" - }; - - var existingEngine = new Engine - { - SerialCode = "1234567890" - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddRange(existingCar, existingEngine); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "engines", - id = existingEngine.StringId, - relationships = new - { - car = new - { - data = new - { - type = "cars", - id = existingCar.StringId - } - } - } - } - }; - - string route = "/engines/" + existingEngine.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Engine engineInDatabase = await dbContext.Engines.Include(engine => engine.Car).FirstWithIdAsync(existingEngine.Id); - - engineInDatabase.Car.Should().NotBeNull(); - engineInDatabase.Car.Id.Should().Be(existingCar.StringId); - }); - } - - [Fact] - public async Task Can_clear_OneToOne_relationship() - { - // Arrange - var existingEngine = new Engine - { - SerialCode = "1234567890", - Car = new Car - { - RegionId = 123, - LicensePlate = "AA-BB-11" - } - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Engines.Add(existingEngine); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "engines", - id = existingEngine.StringId, - relationships = new - { - car = new - { - data = (object)null - } - } - } - }; - - string route = "/engines/" + existingEngine.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Engine engineInDatabase = await dbContext.Engines.Include(engine => engine.Car).FirstWithIdAsync(existingEngine.Id); - - engineInDatabase.Car.Should().BeNull(); - }); - } - - [Fact] - public async Task Can_remove_from_OneToMany_relationship() - { - // Arrange - var existingDealership = new Dealership - { - Address = "Dam 1, 1012JS Amsterdam, the Netherlands", - Inventory = new HashSet - { - new Car - { - RegionId = 123, - LicensePlate = "AA-BB-11" - }, - new Car - { - RegionId = 456, - LicensePlate = "CC-DD-22" - } - } - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Dealerships.Add(existingDealership); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "cars", - id = "123:AA-BB-11" - } - } - }; - - string route = $"/dealerships/{existingDealership.StringId}/relationships/inventory"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Dealership dealershipInDatabase = await dbContext.Dealerships - .Include(dealership => dealership.Inventory).FirstWithIdOrDefaultAsync(existingDealership.Id); - - dealershipInDatabase.Inventory.Should().HaveCount(1); - dealershipInDatabase.Inventory.Should().ContainSingle(car => car.Id == existingDealership.Inventory.ElementAt(1).Id); - }); - } - - [Fact] - public async Task Can_add_to_OneToMany_relationship() - { - // Arrange - var existingDealership = new Dealership - { - Address = "Dam 1, 1012JS Amsterdam, the Netherlands" - }; - - var existingCar = new Car - { - RegionId = 123, - LicensePlate = "AA-BB-11" - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddRange(existingDealership, existingCar); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "cars", - id = "123:AA-BB-11" - } - } - }; - - string route = $"/dealerships/{existingDealership.StringId}/relationships/inventory"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Dealership dealershipInDatabase = await dbContext.Dealerships - .Include(dealership => dealership.Inventory).FirstWithIdOrDefaultAsync(existingDealership.Id); - - dealershipInDatabase.Inventory.Should().HaveCount(1); - dealershipInDatabase.Inventory.Should().ContainSingle(car => car.Id == existingCar.Id); - }); - } - - [Fact] - public async Task Can_replace_OneToMany_relationship() - { - // Arrange - var existingDealership = new Dealership - { - Address = "Dam 1, 1012JS Amsterdam, the Netherlands", - Inventory = new HashSet - { - new Car - { - RegionId = 123, - LicensePlate = "AA-BB-11" - }, - new Car - { - RegionId = 456, - LicensePlate = "CC-DD-22" - } - } - }; - - var existingCar = new Car - { - RegionId = 789, - LicensePlate = "EE-FF-33" - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddRange(existingDealership, existingCar); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "cars", - id = "123:AA-BB-11" - }, - new - { - type = "cars", - id = "789:EE-FF-33" - } - } - }; - - string route = $"/dealerships/{existingDealership.StringId}/relationships/inventory"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Dealership dealershipInDatabase = await dbContext.Dealerships - .Include(dealership => dealership.Inventory).FirstWithIdOrDefaultAsync(existingDealership.Id); - - 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); - }); - } - - [Fact] - public async Task Cannot_remove_from_ManyToOne_relationship_for_unknown_relationship_ID() - { - // Arrange - var existingDealership = new Dealership - { - Address = "Dam 1, 1012JS Amsterdam, the Netherlands" - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Dealerships.Add(existingDealership); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "cars", - id = "999:XX-YY-22" - } - } - }; - - string route = $"/dealerships/{existingDealership.StringId}/relationships/inventory"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 'cars' with ID '999:XX-YY-22' in relationship 'inventory' does not exist."); - } - - [Fact] - public async Task Can_delete_resource() - { - // Arrange - var existingCar = new Car - { - RegionId = 123, - LicensePlate = "AA-BB-11" - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Cars.Add(existingCar); - await dbContext.SaveChangesAsync(); - }); - - string route = "/cars/" + existingCar.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Car carInDatabase = - await dbContext.Cars.FirstOrDefaultAsync(car => car.RegionId == existingCar.RegionId && car.LicensePlate == existingCar.LicensePlate); - - carInDatabase.Should().BeNull(); - }); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Dealership.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Dealership.cs deleted file mode 100644 index aaa1449254..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Dealership.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Dealership : Identifiable - { - [Attr] - public string Address { get; set; } - - [HasMany] - public ISet Inventory { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/DealershipsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/DealershipsController.cs deleted file mode 100644 index 7301033afd..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/DealershipsController.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys -{ - public sealed class DealershipsController : JsonApiController - { - public DealershipsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Engine.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Engine.cs deleted file mode 100644 index 8ccbada031..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/Engine.cs +++ /dev/null @@ -1,16 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Engine : Identifiable - { - [Attr] - public string SerialCode { get; set; } - - [HasOne] - public Car Car { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/EnginesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/EnginesController.cs deleted file mode 100644 index b4371cd63d..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CompositeKeys/EnginesController.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CompositeKeys -{ - public sealed class EnginesController : JsonApiController - { - public EnginesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs deleted file mode 100644 index 8d316708e7..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/AcceptHeaderTests.cs +++ /dev/null @@ -1,251 +0,0 @@ -using System; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Controllers; -using JsonApiDotNetCoreExampleTests.Startups; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ContentNegotiation -{ - public sealed class AcceptHeaderTests : IClassFixture, PolicyDbContext>> - { - private readonly ExampleIntegrationTestContext, PolicyDbContext> _testContext; - - public AcceptHeaderTests(ExampleIntegrationTestContext, PolicyDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - testContext.UseController(); - } - - [Fact] - public async Task Permits_no_Accept_headers() - { - // Arrange - const string route = "/policies"; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - } - - [Fact] - public async Task Permits_no_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"; - const string contentType = HeaderConstants.AtomicOperationsMediaType; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - } - - [Fact] - public async Task Permits_global_wildcard_in_Accept_headers() - { - // Arrange - const string route = "/policies"; - - Action setRequestHeaders = headers => - { - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("text/html")); - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("*/*")); - }; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - } - - [Fact] - public async Task Permits_application_wildcard_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")); - }; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - } - - [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(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")); - }; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - } - - [Fact] - public async Task Permits_JsonApi_with_AtomicOperations_extension_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"; - const string contentType = HeaderConstants.AtomicOperationsMediaType; - - 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")); - }; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType, setRequestHeaders); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - } - - [Fact] - public async Task Denies_JsonApi_with_parameters_in_Accept_headers() - { - // Arrange - const string route = "/policies"; - - 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)); - }; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route, setRequestHeaders); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotAcceptable); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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."); - } - - [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"; - const string contentType = HeaderConstants.AtomicOperationsMediaType; - - Action setRequestHeaders = headers => - { - headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse(HeaderConstants.MediaType)); - }; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = - await _testContext.ExecutePostAsync(route, requestBody, contentType, setRequestHeaders); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotAcceptable); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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."); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs deleted file mode 100644 index 8a2a831eca..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/ContentTypeHeaderTests.cs +++ /dev/null @@ -1,381 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExample.Controllers; -using JsonApiDotNetCoreExampleTests.Startups; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ContentNegotiation -{ - public sealed class ContentTypeHeaderTests : IClassFixture, PolicyDbContext>> - { - private readonly ExampleIntegrationTestContext, PolicyDbContext> _testContext; - - public ContentTypeHeaderTests(ExampleIntegrationTestContext, PolicyDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - testContext.UseController(); - } - - [Fact] - public async Task Returns_JsonApi_ContentType_header() - { - // Arrange - const string route = "/policies"; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.MediaType); - } - - [Fact] - public async Task Returns_JsonApi_ContentType_header_with_AtomicOperations_extension() - { - // Arrange - var requestBody = new - { - atomic__operations = new[] - { - new - { - op = "add", - data = new - { - type = "policies", - attributes = new - { - name = "some" - } - } - } - } - }; - - const string route = "/operations"; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAtomicAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - httpResponse.Content.Headers.ContentType.ToString().Should().Be(HeaderConstants.AtomicOperationsMediaType); - } - - [Fact] - public async Task Denies_unknown_ContentType_header() - { - // Arrange - var requestBody = new - { - data = new - { - type = "policies", - attributes = new - { - name = "some" - } - } - }; - - const string route = "/policies"; - const string contentType = "text/html"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = - await _testContext.ExecutePostAsync(route, requestBody, contentType); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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."); - } - - [Fact] - public async Task Permits_JsonApi_ContentType_header() - { - // Arrange - var requestBody = new - { - data = new - { - type = "policies", - attributes = new - { - name = "some" - } - } - }; - - const string route = "/policies"; - const string contentType = HeaderConstants.MediaType; - - // Act - // ReSharper disable once RedundantArgumentDefaultValue - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - } - - [Fact] - public async Task Permits_JsonApi_ContentType_header_with_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"; - const string contentType = HeaderConstants.AtomicOperationsMediaType; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody, contentType); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - } - - [Fact] - public async Task Denies_JsonApi_ContentType_header_with_profile() - { - // Arrange - var requestBody = new - { - data = new - { - type = "policies", - attributes = new - { - name = "some" - } - } - }; - - const string route = "/policies"; - const string contentType = HeaderConstants.MediaType + "; profile=something"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = - await _testContext.ExecutePostAsync(route, requestBody, contentType); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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."); - } - - [Fact] - public async Task Denies_JsonApi_ContentType_header_with_extension() - { - // Arrange - var requestBody = new - { - data = new - { - type = "policies", - attributes = new - { - name = "some" - } - } - }; - - const string route = "/policies"; - const string contentType = HeaderConstants.MediaType + "; ext=something"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = - await _testContext.ExecutePostAsync(route, requestBody, contentType); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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."); - } - - [Fact] - public async Task Denies_JsonApi_ContentType_header_with_AtomicOperations_extension_at_resource_endpoint() - { - // Arrange - var requestBody = new - { - data = new - { - type = "policies", - attributes = new - { - name = "some" - } - } - }; - - const string route = "/policies"; - const string contentType = HeaderConstants.AtomicOperationsMediaType; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = - await _testContext.ExecutePostAsync(route, requestBody, contentType); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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."); - } - - [Fact] - public async Task Denies_JsonApi_ContentType_header_with_CharSet() - { - // Arrange - var requestBody = new - { - data = new - { - type = "policies", - attributes = new - { - name = "some" - } - } - }; - - const string route = "/policies"; - const string contentType = HeaderConstants.MediaType + "; charset=ISO-8859-4"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = - await _testContext.ExecutePostAsync(route, requestBody, contentType); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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."); - } - - [Fact] - public async Task Denies_JsonApi_ContentType_header_with_unknown_parameter() - { - // Arrange - var requestBody = new - { - data = new - { - type = "policies", - attributes = new - { - name = "some" - } - } - }; - - const string route = "/policies"; - const string contentType = HeaderConstants.MediaType + "; unknown=unexpected"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = - await _testContext.ExecutePostAsync(route, requestBody, contentType); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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."); - } - - [Fact] - public async Task Denies_JsonApi_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 = HeaderConstants.MediaType; - - // Act - // ReSharper disable once RedundantArgumentDefaultValue - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = - await _testContext.ExecutePostAsync(route, requestBody, contentType); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnsupportedMediaType); - - responseDocument.Errors.Should().HaveCount(1); - - string detail = $"Please specify '{HeaderConstants.AtomicOperationsMediaType}' instead of '{contentType}' for the Content-Type header value."; - - Error 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); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/PoliciesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/PoliciesController.cs deleted file mode 100644 index 5cf5119f08..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/PoliciesController.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ContentNegotiation -{ - public sealed class PoliciesController : JsonApiController - { - public PoliciesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/Policy.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/Policy.cs deleted file mode 100644 index 155bcea4a4..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/Policy.cs +++ /dev/null @@ -1,13 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ContentNegotiation -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Policy : Identifiable - { - [Attr] - public string Name { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/PolicyDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/PolicyDbContext.cs deleted file mode 100644 index f5481e1865..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ContentNegotiation/PolicyDbContext.cs +++ /dev/null @@ -1,16 +0,0 @@ -using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ContentNegotiation -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class PolicyDbContext : DbContext - { - public DbSet Policies { get; set; } - - public PolicyDbContext(DbContextOptions options) - : base(options) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ActionResultDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ActionResultDbContext.cs deleted file mode 100644 index 9407c8f255..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ActionResultDbContext.cs +++ /dev/null @@ -1,16 +0,0 @@ -using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ControllerActionResults -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class ActionResultDbContext : DbContext - { - public DbSet Toothbrushes { get; set; } - - public ActionResultDbContext(DbContextOptions options) - : base(options) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs deleted file mode 100644 index 2e23a8746c..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ActionResultTests.cs +++ /dev/null @@ -1,157 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExampleTests.Startups; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ControllerActionResults -{ - public sealed class ActionResultTests : IClassFixture, ActionResultDbContext>> - { - private readonly ExampleIntegrationTestContext, ActionResultDbContext> _testContext; - - public ActionResultTests(ExampleIntegrationTestContext, ActionResultDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task Can_get_resource_by_ID() - { - // Arrange - var toothbrush = new Toothbrush(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Toothbrushes.Add(toothbrush); - await dbContext.SaveChangesAsync(); - }); - - string route = "/toothbrushes/" + toothbrush.StringId; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Id.Should().Be(toothbrush.StringId); - } - - [Fact] - public async Task Converts_empty_ActionResult_to_error_collection() - { - // Arrange - string route = "/toothbrushes/" + BaseToothbrushesController.EmptyActionResultId; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("NotFound"); - error.Detail.Should().BeNull(); - } - - [Fact] - public async Task Converts_ActionResult_with_error_object_to_error_collection() - { - // Arrange - string route = "/toothbrushes/" + BaseToothbrushesController.ActionResultWithErrorObjectId; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.NotFound); - error.Title.Should().Be("No toothbrush with that ID exists."); - error.Detail.Should().BeNull(); - } - - [Fact] - public async Task Cannot_convert_ActionResult_with_string_parameter_to_error_collection() - { - // Arrange - string route = "/toothbrushes/" + BaseToothbrushesController.ActionResultWithStringParameter; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.InternalServerError); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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("Data being returned must be errors or resources."); - } - - [Fact] - public async Task Converts_ObjectResult_with_error_object_to_error_collection() - { - // Arrange - string route = "/toothbrushes/" + BaseToothbrushesController.ObjectResultWithErrorObjectId; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadGateway); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadGateway); - error.Title.Should().BeNull(); - error.Detail.Should().BeNull(); - } - - [Fact] - public async Task Converts_ObjectResult_with_error_objects_to_error_collection() - { - // Arrange - string route = "/toothbrushes/" + BaseToothbrushesController.ObjectResultWithErrorCollectionId; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.Should().HaveCount(3); - - Error error1 = responseDocument.Errors[0]; - error1.StatusCode.Should().Be(HttpStatusCode.PreconditionFailed); - error1.Title.Should().BeNull(); - error1.Detail.Should().BeNull(); - - Error error2 = responseDocument.Errors[1]; - error2.StatusCode.Should().Be(HttpStatusCode.Unauthorized); - error2.Title.Should().BeNull(); - error2.Detail.Should().BeNull(); - - Error error3 = responseDocument.Errors[2]; - error3.StatusCode.Should().Be(HttpStatusCode.ExpectationFailed); - error3.Title.Should().Be("This is not a very great request."); - error3.Detail.Should().BeNull(); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/BaseToothbrushesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/BaseToothbrushesController.cs deleted file mode 100644 index 00fb4ac4dc..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/BaseToothbrushesController.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System.Net; -using System.Threading; -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCore.Services; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ControllerActionResults -{ - public abstract class BaseToothbrushesController : BaseJsonApiController - { - internal const int EmptyActionResultId = 11111111; - internal const int ActionResultWithErrorObjectId = 22222222; - internal const int ActionResultWithStringParameter = 33333333; - internal const int ObjectResultWithErrorObjectId = 44444444; - internal const int ObjectResultWithErrorCollectionId = 55555555; - - protected BaseToothbrushesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - - public override async Task GetAsync(int id, CancellationToken cancellationToken) - { - if (id == EmptyActionResultId) - { - return NotFound(); - } - - if (id == ActionResultWithErrorObjectId) - { - return NotFound(new Error(HttpStatusCode.NotFound) - { - Title = "No toothbrush with that ID exists." - }); - } - - if (id == ActionResultWithStringParameter) - { - return Conflict("Something went wrong."); - } - - if (id == ObjectResultWithErrorObjectId) - { - return Error(new Error(HttpStatusCode.BadGateway)); - } - - if (id == ObjectResultWithErrorCollectionId) - { - var errors = new[] - { - new Error(HttpStatusCode.PreconditionFailed), - new Error(HttpStatusCode.Unauthorized), - new Error(HttpStatusCode.ExpectationFailed) - { - Title = "This is not a very great request." - } - }; - - return Error(errors); - } - - return await base.GetAsync(id, cancellationToken); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/Toothbrush.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/Toothbrush.cs deleted file mode 100644 index 3a9f69ec1f..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/Toothbrush.cs +++ /dev/null @@ -1,13 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ControllerActionResults -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Toothbrush : Identifiable - { - [Attr] - public bool IsElectric { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs deleted file mode 100644 index a16b083db4..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ControllerActionResults/ToothbrushesController.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Services; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ControllerActionResults -{ - public sealed class ToothbrushesController : BaseToothbrushesController - { - public ToothbrushesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - - [HttpGet("{id}")] - public override Task GetAsync(int id, CancellationToken cancellationToken) - { - return base.GetAsync(id, cancellationToken); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs deleted file mode 100644 index ecba79c327..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/ApiControllerAttributeTests.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExampleTests.Startups; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CustomRoutes -{ - public sealed class ApiControllerAttributeTests : IClassFixture, CustomRouteDbContext>> - { - private readonly ExampleIntegrationTestContext, CustomRouteDbContext> _testContext; - - public ApiControllerAttributeTests(ExampleIntegrationTestContext, CustomRouteDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - } - - [Fact] - public async Task ApiController_attribute_transforms_NotFound_action_result_without_arguments_into_ProblemDetails() - { - // Arrange - const string route = "/world-civilians/missing"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.Links.About.Should().Be("https://tools.ietf.org/html/rfc7231#section-6.5.4"); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/Civilian.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/Civilian.cs deleted file mode 100644 index 65b3acedc7..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/Civilian.cs +++ /dev/null @@ -1,13 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CustomRoutes -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Civilian : Identifiable - { - [Attr] - public string Name { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CiviliansController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CiviliansController.cs deleted file mode 100644 index 7a0ef9bf3b..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CiviliansController.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Controllers.Annotations; -using JsonApiDotNetCore.Services; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CustomRoutes -{ - [ApiController] - [DisableRoutingConvention] - [Route("world-civilians")] - public sealed class CiviliansController : JsonApiController - { - public CiviliansController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - - [HttpGet("missing")] - public async Task GetMissingAsync() - { - await Task.Yield(); - return NotFound(); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteDbContext.cs deleted file mode 100644 index d368ca14b4..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteDbContext.cs +++ /dev/null @@ -1,17 +0,0 @@ -using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CustomRoutes -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class CustomRouteDbContext : DbContext - { - public DbSet Towns { get; set; } - public DbSet Civilians { get; set; } - - public CustomRouteDbContext(DbContextOptions options) - : base(options) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteFakers.cs deleted file mode 100644 index 9bd81abec4..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteFakers.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; -using Bogus; -using TestBuildingBlocks; - -// @formatter:wrap_chained_method_calls chop_always -// @formatter:keep_existing_linebreaks true - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CustomRoutes -{ - internal sealed class CustomRouteFakers : FakerContainer - { - private readonly Lazy> _lazyTownFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(town => town.Name, faker => faker.Address.City()) - .RuleFor(town => town.Latitude, faker => faker.Address.Latitude()) - .RuleFor(town => town.Longitude, faker => faker.Address.Longitude())); - - private readonly Lazy> _lazyCivilianFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(civilian => civilian.Name, faker => faker.Person.FullName)); - - public Faker Town => _lazyTownFaker.Value; - public Faker Civilian => _lazyCivilianFaker.Value; - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs deleted file mode 100644 index d974d38503..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/CustomRouteTests.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExampleTests.Startups; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CustomRoutes -{ - public sealed class CustomRouteTests : IClassFixture, CustomRouteDbContext>> - { - private const string HostPrefix = "http://localhost"; - - private readonly ExampleIntegrationTestContext, CustomRouteDbContext> _testContext; - private readonly CustomRouteFakers _fakers = new CustomRouteFakers(); - - public CustomRouteTests(ExampleIntegrationTestContext, CustomRouteDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - testContext.UseController(); - } - - [Fact] - public async Task Can_get_resource_at_custom_route() - { - // Arrange - Town town = _fakers.Town.Generate(); - town.Civilians = _fakers.Civilian.Generate(1).ToHashSet(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Towns.Add(town); - await dbContext.SaveChangesAsync(); - }); - - string route = "/world-api/civilization/popular/towns/" + town.StringId; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Type.Should().Be("towns"); - responseDocument.SingleData.Id.Should().Be(town.StringId); - responseDocument.SingleData.Attributes["name"].Should().Be(town.Name); - responseDocument.SingleData.Attributes["latitude"].Should().Be(town.Latitude); - responseDocument.SingleData.Attributes["longitude"].Should().Be(town.Longitude); - responseDocument.SingleData.Relationships["civilians"].Links.Self.Should().Be(HostPrefix + route + "/relationships/civilians"); - responseDocument.SingleData.Relationships["civilians"].Links.Related.Should().Be(HostPrefix + route + "/civilians"); - responseDocument.SingleData.Links.Self.Should().Be(HostPrefix + route); - responseDocument.Links.Self.Should().Be(HostPrefix + route); - } - - [Fact] - public async Task Can_get_resources_at_custom_action_method() - { - // Arrange - List town = _fakers.Town.Generate(7); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Towns.AddRange(town); - await dbContext.SaveChangesAsync(); - }); - - const string route = "/world-api/civilization/popular/towns/largest-5"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(5); - responseDocument.ManyData.Should().OnlyContain(resourceObject => resourceObject.Type == "towns"); - responseDocument.ManyData.Should().OnlyContain(resourceObject => resourceObject.Attributes.Any()); - responseDocument.ManyData.Should().OnlyContain(resourceObject => resourceObject.Relationships.Any()); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/Town.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/Town.cs deleted file mode 100644 index 1242def3a3..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/Town.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CustomRoutes -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Town : Identifiable - { - [Attr] - public string Name { get; set; } - - [Attr] - public double Latitude { get; set; } - - [Attr] - public double Longitude { get; set; } - - [HasMany] - public ISet Civilians { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/TownsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/TownsController.cs deleted file mode 100644 index 4f65adaaf2..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/CustomRoutes/TownsController.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Controllers.Annotations; -using JsonApiDotNetCore.Services; -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.CustomRoutes -{ - [DisableRoutingConvention] - [Route("world-api/civilization/popular/towns")] - public sealed class TownsController : JsonApiController - { - private readonly CustomRouteDbContext _dbContext; - - public TownsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService, CustomRouteDbContext dbContext) - : base(options, loggerFactory, resourceService) - { - _dbContext = dbContext; - } - - [HttpGet("largest-{count}")] - public async Task GetLargestTownsAsync(int count, CancellationToken cancellationToken) - { - IQueryable query = _dbContext.Towns.OrderByDescending(town => town.Civilians.Count).Take(count); - - List results = await query.ToListAsync(cancellationToken); - return Ok(results); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/Building.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/Building.cs deleted file mode 100644 index 48334083aa..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/Building.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations.Schema; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.EagerLoading -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Building : Identifiable - { - private string _tempPrimaryDoorColor; - - [Attr] - public string Number { get; set; } - - [NotMapped] - [Attr] - public int WindowCount => Windows?.Count ?? 0; - - [NotMapped] - [Attr(Capabilities = AttrCapabilities.AllowView | AttrCapabilities.AllowChange)] - public string PrimaryDoorColor - { - get => _tempPrimaryDoorColor ?? PrimaryDoor.Color; - set - { - if (PrimaryDoor == null) - { - // A request body is being deserialized. At this time, related entities have not been loaded. - // We cache the assigned value in a private field, so it can be used later. - _tempPrimaryDoorColor = value; - } - else - { - PrimaryDoor.Color = value; - } - } - } - - [NotMapped] - [Attr(Capabilities = AttrCapabilities.AllowView)] - public string SecondaryDoorColor => SecondaryDoor?.Color; - - [EagerLoad] - public IList Windows { get; set; } - - [EagerLoad] - public Door PrimaryDoor { get; set; } - - [EagerLoad] - public Door SecondaryDoor { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/BuildingRepository.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/BuildingRepository.cs deleted file mode 100644 index 8bf5086e06..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/BuildingRepository.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Repositories; -using JsonApiDotNetCore.Resources; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.EagerLoading -{ - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class BuildingRepository : EntityFrameworkCoreRepository - { - public BuildingRepository(ITargetedFields targetedFields, IDbContextResolver contextResolver, IResourceGraph resourceGraph, - IResourceFactory resourceFactory, IEnumerable constraintProviders, ILoggerFactory loggerFactory) - : base(targetedFields, contextResolver, resourceGraph, resourceFactory, constraintProviders, loggerFactory) - { - } - - public override async Task GetForCreateAsync(int id, CancellationToken cancellationToken) - { - Building building = await base.GetForCreateAsync(id, cancellationToken); - - // Must ensure that an instance exists for this required relationship, so that POST succeeds. - building.PrimaryDoor = new Door(); - - return building; - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/BuildingsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/BuildingsController.cs deleted file mode 100644 index be28a84035..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/BuildingsController.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.EagerLoading -{ - public sealed class BuildingsController : JsonApiController - { - public BuildingsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/City.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/City.cs deleted file mode 100644 index ec8c85b56a..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/City.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.EagerLoading -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class City : Identifiable - { - [Attr] - public string Name { get; set; } - - [HasMany] - public IList Streets { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/Door.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/Door.cs deleted file mode 100644 index 068c06414c..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/Door.cs +++ /dev/null @@ -1,11 +0,0 @@ -using JetBrains.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.EagerLoading -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Door - { - public int Id { get; set; } - public string Color { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingDbContext.cs deleted file mode 100644 index 62dd263f79..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingDbContext.cs +++ /dev/null @@ -1,35 +0,0 @@ -using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore; - -// @formatter:wrap_chained_method_calls chop_always - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.EagerLoading -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class EagerLoadingDbContext : DbContext - { - public DbSet States { get; set; } - public DbSet Streets { get; set; } - public DbSet Buildings { get; set; } - - public EagerLoadingDbContext(DbContextOptions options) - : base(options) - { - } - - protected override void OnModelCreating(ModelBuilder builder) - { - builder.Entity() - .HasOne(building => building.PrimaryDoor) - .WithOne() - .HasForeignKey("PrimaryDoorId") - .IsRequired(); - - builder.Entity() - .HasOne(building => building.SecondaryDoor) - .WithOne() - .HasForeignKey("SecondaryDoorId") - .IsRequired(false); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingFakers.cs deleted file mode 100644 index bb41ba2449..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingFakers.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System; -using Bogus; -using TestBuildingBlocks; - -// @formatter:wrap_chained_method_calls chop_always -// @formatter:keep_existing_linebreaks true - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.EagerLoading -{ - internal sealed class EagerLoadingFakers : FakerContainer - { - private readonly Lazy> _lazyStateFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(state => state.Name, faker => faker.Address.City())); - - private readonly Lazy> _lazyCityFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(city => city.Name, faker => faker.Address.City())); - - private readonly Lazy> _lazyStreetFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(street => street.Name, faker => faker.Address.StreetName())); - - private readonly Lazy> _lazyBuildingFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(building => building.Number, faker => faker.Address.BuildingNumber())); - - private readonly Lazy> _lazyWindowFaker = new Lazy>(() => - 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 Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(door => door.Color, faker => faker.Commerce.Color())); - - public Faker State => _lazyStateFaker.Value; - public Faker City => _lazyCityFaker.Value; - public Faker Street => _lazyStreetFaker.Value; - public Faker Building => _lazyBuildingFaker.Value; - public Faker Window => _lazyWindowFaker.Value; - public Faker Door => _lazyDoorFaker.Value; - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs deleted file mode 100644 index 317c114978..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/EagerLoadingTests.cs +++ /dev/null @@ -1,365 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.EntityFrameworkCore; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.EagerLoading -{ - public sealed class EagerLoadingTests : IClassFixture, EagerLoadingDbContext>> - { - private readonly ExampleIntegrationTestContext, EagerLoadingDbContext> _testContext; - private readonly EagerLoadingFakers _fakers = new EagerLoadingFakers(); - - public EagerLoadingTests(ExampleIntegrationTestContext, EagerLoadingDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - testContext.UseController(); - testContext.UseController(); - - testContext.ConfigureServicesAfterStartup(services => - { - services.AddResourceRepository(); - }); - } - - [Fact] - public async Task Can_get_primary_resource_with_eager_loads() - { - // Arrange - Building building = _fakers.Building.Generate(); - building.Windows = _fakers.Window.Generate(4); - building.PrimaryDoor = _fakers.Door.Generate(); - building.SecondaryDoor = _fakers.Door.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Buildings.Add(building); - await dbContext.SaveChangesAsync(); - }); - - string route = "/buildings/" + building.StringId; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Id.Should().Be(building.StringId); - responseDocument.SingleData.Attributes["number"].Should().Be(building.Number); - responseDocument.SingleData.Attributes["windowCount"].Should().Be(4); - responseDocument.SingleData.Attributes["primaryDoorColor"].Should().Be(building.PrimaryDoor.Color); - responseDocument.SingleData.Attributes["secondaryDoorColor"].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.Buildings[0].Windows = _fakers.Window.Generate(2); - street.Buildings[0].PrimaryDoor = _fakers.Door.Generate(); - - street.Buildings[1].Windows = _fakers.Window.Generate(3); - street.Buildings[1].PrimaryDoor = _fakers.Door.Generate(); - street.Buildings[1].SecondaryDoor = _fakers.Door.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Streets.Add(street); - await dbContext.SaveChangesAsync(); - }); - - string route = "/streets/" + street.StringId; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Id.Should().Be(street.StringId); - responseDocument.SingleData.Attributes["name"].Should().Be(street.Name); - responseDocument.SingleData.Attributes["buildingCount"].Should().Be(2); - responseDocument.SingleData.Attributes["doorTotalCount"].Should().Be(3); - responseDocument.SingleData.Attributes["windowTotalCount"].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(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Streets.Add(street); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/streets/{street.StringId}?fields[streets]=windowTotalCount"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Id.Should().Be(street.StringId); - responseDocument.SingleData.Attributes.Should().HaveCount(1); - responseDocument.SingleData.Attributes["windowTotalCount"].Should().Be(3); - responseDocument.SingleData.Relationships.Should().BeNull(); - } - - [Fact] - 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); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.States.Add(state); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/states/{state.StringId}?include=cities.streets"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Id.Should().Be(state.StringId); - responseDocument.SingleData.Attributes["name"].Should().Be(state.Name); - - 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["name"].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["buildingCount"].Should().Be(1); - responseDocument.Included[1].Attributes["doorTotalCount"].Should().Be(1); - responseDocument.Included[1].Attributes["windowTotalCount"].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); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.States.Add(state); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/states/{state.StringId}/cities?include=streets&fields[cities]=name&fields[streets]=doorTotalCount,windowTotalCount"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Id.Should().Be(state.Cities[0].StringId); - responseDocument.ManyData[0].Attributes.Should().HaveCount(1); - responseDocument.ManyData[0].Attributes["name"].Should().Be(state.Cities[0].Name); - responseDocument.ManyData[0].Relationships.Should().BeNull(); - - 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.Should().HaveCount(2); - responseDocument.Included[0].Attributes["doorTotalCount"].Should().Be(2); - responseDocument.Included[0].Attributes["windowTotalCount"].Should().Be(1); - responseDocument.Included[0].Relationships.Should().BeNull(); - } - - [Fact] - public async Task Can_create_resource() - { - // Arrange - Building newBuilding = _fakers.Building.Generate(); - - var requestBody = new - { - data = new - { - type = "buildings", - attributes = new - { - number = newBuilding.Number - } - } - }; - - const string route = "/buildings"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["number"].Should().Be(newBuilding.Number); - responseDocument.SingleData.Attributes["windowCount"].Should().Be(0); - responseDocument.SingleData.Attributes["primaryDoorColor"].Should().BeNull(); - responseDocument.SingleData.Attributes["secondaryDoorColor"].Should().BeNull(); - - int newBuildingId = int.Parse(responseDocument.SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - Building buildingInDatabase = await dbContext.Buildings - .Include(building => building.PrimaryDoor) - .Include(building => building.SecondaryDoor) - .Include(building => building.Windows) - .FirstWithIdOrDefaultAsync(newBuildingId); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - buildingInDatabase.Should().NotBeNull(); - buildingInDatabase.Number.Should().Be(newBuilding.Number); - buildingInDatabase.PrimaryDoor.Should().NotBeNull(); - buildingInDatabase.SecondaryDoor.Should().BeNull(); - buildingInDatabase.Windows.Should().BeEmpty(); - }); - } - - [Fact] - 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); - - string newBuildingNumber = _fakers.Building.Generate().Number; - string newPrimaryDoorColor = _fakers.Door.Generate().Color; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Buildings.Add(existingBuilding); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "buildings", - id = existingBuilding.StringId, - attributes = new - { - number = newBuildingNumber, - primaryDoorColor = newPrimaryDoorColor - } - } - }; - - string route = "/buildings/" + existingBuilding.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - // @formatter:wrap_chained_method_calls chop_always - // @formatter:keep_existing_linebreaks true - - Building buildingInDatabase = await dbContext.Buildings - .Include(building => building.PrimaryDoor) - .Include(building => building.SecondaryDoor) - .Include(building => building.Windows) - .FirstWithIdOrDefaultAsync(existingBuilding.Id); - - // @formatter:keep_existing_linebreaks restore - // @formatter:wrap_chained_method_calls restore - - buildingInDatabase.Should().NotBeNull(); - buildingInDatabase.Number.Should().Be(newBuildingNumber); - buildingInDatabase.PrimaryDoor.Should().NotBeNull(); - buildingInDatabase.PrimaryDoor.Color.Should().Be(newPrimaryDoorColor); - buildingInDatabase.SecondaryDoor.Should().NotBeNull(); - buildingInDatabase.Windows.Should().HaveCount(2); - }); - } - - [Fact] - public async Task Can_delete_resource() - { - // Arrange - Building existingBuilding = _fakers.Building.Generate(); - existingBuilding.PrimaryDoor = _fakers.Door.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Buildings.Add(existingBuilding); - await dbContext.SaveChangesAsync(); - }); - - string route = "/buildings/" + existingBuilding.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - Building buildingInDatabase = await dbContext.Buildings.FirstWithIdOrDefaultAsync(existingBuilding.Id); - - buildingInDatabase.Should().BeNull(); - }); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/State.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/State.cs deleted file mode 100644 index 99e46ff0a3..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/State.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.EagerLoading -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class State : Identifiable - { - [Attr] - public string Name { get; set; } - - [HasMany] - public IList Cities { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/StatesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/StatesController.cs deleted file mode 100644 index bbaf58cfb2..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/StatesController.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.EagerLoading -{ - public sealed class StatesController : JsonApiController - { - public StatesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/Street.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/Street.cs deleted file mode 100644 index 6ea7264c67..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/Street.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations.Schema; -using System.Linq; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.EagerLoading -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Street : Identifiable - { - [Attr] - public string Name { get; set; } - - [NotMapped] - [Attr(Capabilities = AttrCapabilities.AllowView)] - public int BuildingCount => Buildings?.Count ?? 0; - - [NotMapped] - [Attr(Capabilities = AttrCapabilities.AllowView)] - public int DoorTotalCount => Buildings?.Sum(building => building.SecondaryDoor == null ? 1 : 2) ?? 0; - - [NotMapped] - [Attr(Capabilities = AttrCapabilities.AllowView)] - public int WindowTotalCount => Buildings?.Sum(building => building.WindowCount) ?? 0; - - [EagerLoad] - public IList Buildings { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/StreetsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/StreetsController.cs deleted file mode 100644 index 04b4275495..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/StreetsController.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.EagerLoading -{ - public sealed class StreetsController : JsonApiController - { - public StreetsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/Window.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/Window.cs deleted file mode 100644 index 88dd4a6896..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/EagerLoading/Window.cs +++ /dev/null @@ -1,12 +0,0 @@ -using JetBrains.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.EagerLoading -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Window - { - public int Id { get; set; } - public int HeightInCentimeters { get; set; } - public int WidthInCentimeters { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/AlternateExceptionHandler.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/AlternateExceptionHandler.cs deleted file mode 100644 index 385d2d6493..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/AlternateExceptionHandler.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Serialization.Objects; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ExceptionHandling -{ - public sealed class AlternateExceptionHandler : ExceptionHandler - { - public AlternateExceptionHandler(ILoggerFactory loggerFactory, IJsonApiOptions options) - : base(loggerFactory, options) - { - } - - protected override LogLevel GetLogLevel(Exception exception) - { - if (exception is ConsumerArticleIsNoLongerAvailableException) - { - return LogLevel.Warning; - } - - return base.GetLogLevel(exception); - } - - protected override ErrorDocument CreateErrorDocument(Exception exception) - { - if (exception is ConsumerArticleIsNoLongerAvailableException articleException) - { - articleException.Errors[0].Meta.Data.Add("support", - $"Please contact us for info about similar articles at {articleException.SupportEmailAddress}."); - } - - return base.CreateErrorDocument(exception); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticle.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticle.cs deleted file mode 100644 index a7c2b686e8..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticle.cs +++ /dev/null @@ -1,13 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ExceptionHandling -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class ConsumerArticle : Identifiable - { - [Attr] - public string Code { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticleIsNoLongerAvailableException.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticleIsNoLongerAvailableException.cs deleted file mode 100644 index bae3760b76..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticleIsNoLongerAvailableException.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Net; -using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ExceptionHandling -{ - internal sealed class ConsumerArticleIsNoLongerAvailableException : JsonApiException - { - public string SupportEmailAddress { get; } - - public ConsumerArticleIsNoLongerAvailableException(string articleCode, string supportEmailAddress) - : base(new Error(HttpStatusCode.Gone) - { - Title = "The requested article is no longer available.", - Detail = $"Article with code '{articleCode}' is no longer available." - }) - { - SupportEmailAddress = supportEmailAddress; - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs deleted file mode 100644 index 2b63cf8a1e..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticleService.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Hooks; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Repositories; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ExceptionHandling -{ - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class ConsumerArticleService : JsonApiResourceService - { - 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, IResourceHookExecutorFacade hookExecutor) - : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, hookExecutor) - { - } - - public override async Task GetAsync(int id, CancellationToken cancellationToken) - { - ConsumerArticle consumerArticle = await base.GetAsync(id, cancellationToken); - - if (consumerArticle.Code.StartsWith(UnavailableArticlePrefix, StringComparison.Ordinal)) - { - throw new ConsumerArticleIsNoLongerAvailableException(consumerArticle.Code, SupportEmailAddress); - } - - return consumerArticle; - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticlesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticlesController.cs deleted file mode 100644 index 7b36586706..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ConsumerArticlesController.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ExceptionHandling -{ - public sealed class ConsumerArticlesController : JsonApiController - { - public ConsumerArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ErrorDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ErrorDbContext.cs deleted file mode 100644 index ff9abd6509..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ErrorDbContext.cs +++ /dev/null @@ -1,17 +0,0 @@ -using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ExceptionHandling -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class ErrorDbContext : DbContext - { - public DbSet ConsumerArticles { get; set; } - public DbSet ThrowingArticles { get; set; } - - public ErrorDbContext(DbContextOptions options) - : base(options) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs deleted file mode 100644 index 1a9a272c03..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ExceptionHandlerTests.cs +++ /dev/null @@ -1,133 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json.Linq; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ExceptionHandling -{ - public sealed class ExceptionHandlerTests : IClassFixture, ErrorDbContext>> - { - private readonly ExampleIntegrationTestContext, ErrorDbContext> _testContext; - - public ExceptionHandlerTests(ExampleIntegrationTestContext, ErrorDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - testContext.UseController(); - - FakeLoggerFactory loggerFactory = null; - - testContext.ConfigureLogging(options => - { - loggerFactory = new FakeLoggerFactory(); - - options.ClearProviders(); - options.AddProvider(loggerFactory); - options.SetMinimumLevel(LogLevel.Warning); - }); - - testContext.ConfigureServicesBeforeStartup(services => - { - if (loggerFactory != null) - { - services.AddSingleton(_ => loggerFactory); - } - }); - - testContext.ConfigureServicesAfterStartup(services => - { - services.AddResourceService(); - services.AddScoped(); - }); - } - - [Fact] - public async Task Logs_and_produces_error_response_for_custom_exception() - { - // Arrange - var loggerFactory = _testContext.Factory.Services.GetRequiredService(); - loggerFactory.Logger.Clear(); - - var consumerArticle = new ConsumerArticle - { - Code = ConsumerArticleService.UnavailableArticlePrefix + "123" - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.ConsumerArticles.Add(consumerArticle); - await dbContext.SaveChangesAsync(); - }); - - string route = "/consumerArticles/" + consumerArticle.StringId; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Gone); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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.Data["support"].Should().Be("Please contact us for info about similar articles at company@email.com."); - - loggerFactory.Logger.Messages.Should().HaveCount(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."); - } - - [Fact] - public async Task Logs_and_produces_error_response_on_serialization_failure() - { - // Arrange - var loggerFactory = _testContext.Factory.Services.GetRequiredService(); - loggerFactory.Logger.Clear(); - - var throwingArticle = new ThrowingArticle(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.ThrowingArticles.Add(throwingArticle); - await dbContext.SaveChangesAsync(); - }); - - string route = "/throwingArticles/" + throwingArticle.StringId; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.InternalServerError); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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."); - - IEnumerable stackTraceLines = ((JArray)error.Meta.Data["stackTrace"]).Select(token => token.Value()); - stackTraceLines.Should().ContainMatch("* System.InvalidOperationException: Article status could not be determined.*"); - - loggerFactory.Logger.Messages.Should().HaveCount(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."); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ThrowingArticle.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ThrowingArticle.cs deleted file mode 100644 index fe2d61c6d6..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ThrowingArticle.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using System.ComponentModel.DataAnnotations.Schema; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ExceptionHandling -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class ThrowingArticle : Identifiable - { - [Attr] - [NotMapped] - public string Status => throw new InvalidOperationException("Article status could not be determined."); - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ThrowingArticlesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ThrowingArticlesController.cs deleted file mode 100644 index f2e6def6ed..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ExceptionHandling/ThrowingArticlesController.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ExceptionHandling -{ - public sealed class ThrowingArticlesController : JsonApiController - { - public ThrowingArticlesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/HostingInIIS/ArtGalleriesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/HostingInIIS/ArtGalleriesController.cs deleted file mode 100644 index 4ed2ccb73e..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/HostingInIIS/ArtGalleriesController.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.HostingInIIS -{ - public sealed class ArtGalleriesController : JsonApiController - { - public ArtGalleriesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/HostingInIIS/ArtGallery.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/HostingInIIS/ArtGallery.cs deleted file mode 100644 index b3fd2d7317..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/HostingInIIS/ArtGallery.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.HostingInIIS -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class ArtGallery : Identifiable - { - [Attr] - public string Theme { get; set; } - - [HasMany] - public ISet Paintings { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/HostingInIIS/HostingDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/HostingInIIS/HostingDbContext.cs deleted file mode 100644 index 82d77b2b16..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/HostingInIIS/HostingDbContext.cs +++ /dev/null @@ -1,17 +0,0 @@ -using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.HostingInIIS -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class HostingDbContext : DbContext - { - public DbSet ArtGalleries { get; set; } - public DbSet Paintings { get; set; } - - public HostingDbContext(DbContextOptions options) - : base(options) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/HostingInIIS/HostingFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/HostingInIIS/HostingFakers.cs deleted file mode 100644 index b67b79ab6e..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/HostingInIIS/HostingFakers.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using Bogus; -using TestBuildingBlocks; - -// @formatter:wrap_chained_method_calls chop_always -// @formatter:keep_existing_linebreaks true - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.HostingInIIS -{ - internal sealed class HostingFakers : FakerContainer - { - private readonly Lazy> _lazyArtGalleryFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(artGallery => artGallery.Theme, faker => faker.Lorem.Word())); - - private readonly Lazy> _lazyPaintingFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(painting => painting.Title, faker => faker.Lorem.Sentence())); - - public Faker ArtGallery => _lazyArtGalleryFaker.Value; - public Faker Painting => _lazyPaintingFaker.Value; - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/HostingInIIS/HostingStartup.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/HostingInIIS/HostingStartup.cs deleted file mode 100644 index 2b369b3c6e..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/HostingInIIS/HostingStartup.cs +++ /dev/null @@ -1,29 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.EntityFrameworkCore; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.HostingInIIS -{ - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class HostingStartup : TestableStartup - where TDbContext : DbContext - { - protected override void SetJsonApiOptions(JsonApiOptions options) - { - base.SetJsonApiOptions(options); - - options.Namespace = "public-api"; - options.IncludeTotalResourceCount = true; - } - - public override void Configure(IApplicationBuilder app, IWebHostEnvironment environment) - { - app.UsePathBase("/iis-application-virtual-directory"); - - base.Configure(app, environment); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/HostingInIIS/HostingTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/HostingInIIS/HostingTests.cs deleted file mode 100644 index d3ea6b1054..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/HostingInIIS/HostingTests.cs +++ /dev/null @@ -1,116 +0,0 @@ -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.HostingInIIS -{ - public sealed class HostingTests : IClassFixture, HostingDbContext>> - { - private const string HostPrefix = "http://localhost"; - - private readonly ExampleIntegrationTestContext, HostingDbContext> _testContext; - private readonly HostingFakers _fakers = new HostingFakers(); - - public HostingTests(ExampleIntegrationTestContext, HostingDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - testContext.UseController(); - } - - [Fact] - public async Task Get_primary_resources_with_include_returns_links() - { - // Arrange - ArtGallery gallery = _fakers.ArtGallery.Generate(); - gallery.Paintings = _fakers.Painting.Generate(1).ToHashSet(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.ArtGalleries.Add(gallery); - await dbContext.SaveChangesAsync(); - }); - - const string route = "/iis-application-virtual-directory/public-api/artGalleries?include=paintings"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Links.Self.Should().Be(HostPrefix + route); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be(HostPrefix + route); - responseDocument.Links.Last.Should().Be(HostPrefix + route); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); - - string galleryLink = HostPrefix + $"/iis-application-virtual-directory/public-api/artGalleries/{gallery.StringId}"; - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Links.Self.Should().Be(galleryLink); - responseDocument.ManyData[0].Relationships["paintings"].Links.Self.Should().Be(galleryLink + "/relationships/paintings"); - responseDocument.ManyData[0].Relationships["paintings"].Links.Related.Should().Be(galleryLink + "/paintings"); - - string paintingLink = HostPrefix + - $"/iis-application-virtual-directory/custom/path/to/paintings-of-the-world/{gallery.Paintings.ElementAt(0).StringId}"; - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(paintingLink); - responseDocument.Included[0].Relationships["exposedAt"].Links.Self.Should().Be(paintingLink + "/relationships/exposedAt"); - responseDocument.Included[0].Relationships["exposedAt"].Links.Related.Should().Be(paintingLink + "/exposedAt"); - } - - [Fact] - public async Task Get_primary_resources_with_include_on_custom_route_returns_links() - { - // Arrange - Painting painting = _fakers.Painting.Generate(); - painting.ExposedAt = _fakers.ArtGallery.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Paintings.Add(painting); - await dbContext.SaveChangesAsync(); - }); - - const string route = "/iis-application-virtual-directory/custom/path/to/paintings-of-the-world?include=exposedAt"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Links.Self.Should().Be(HostPrefix + route); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be(HostPrefix + route); - responseDocument.Links.Last.Should().Be(HostPrefix + route); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); - - string paintingLink = HostPrefix + $"/iis-application-virtual-directory/custom/path/to/paintings-of-the-world/{painting.StringId}"; - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Links.Self.Should().Be(paintingLink); - responseDocument.ManyData[0].Relationships["exposedAt"].Links.Self.Should().Be(paintingLink + "/relationships/exposedAt"); - responseDocument.ManyData[0].Relationships["exposedAt"].Links.Related.Should().Be(paintingLink + "/exposedAt"); - - string galleryLink = HostPrefix + $"/iis-application-virtual-directory/public-api/artGalleries/{painting.ExposedAt.StringId}"; - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(galleryLink); - responseDocument.Included[0].Relationships["paintings"].Links.Self.Should().Be(galleryLink + "/relationships/paintings"); - responseDocument.Included[0].Relationships["paintings"].Links.Related.Should().Be(galleryLink + "/paintings"); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/HostingInIIS/Painting.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/HostingInIIS/Painting.cs deleted file mode 100644 index 2b4c3d5d4f..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/HostingInIIS/Painting.cs +++ /dev/null @@ -1,16 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.HostingInIIS -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Painting : Identifiable - { - [Attr] - public string Title { get; set; } - - [HasOne] - public ArtGallery ExposedAt { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/HostingInIIS/PaintingsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/HostingInIIS/PaintingsController.cs deleted file mode 100644 index f79d36fe92..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/HostingInIIS/PaintingsController.cs +++ /dev/null @@ -1,19 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Controllers.Annotations; -using JsonApiDotNetCore.Services; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.HostingInIIS -{ - [DisableRoutingConvention] - [Route("custom/path/to/paintings-of-the-world")] - public sealed class PaintingsController : JsonApiController - { - public PaintingsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/BankAccount.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/BankAccount.cs deleted file mode 100644 index da98000a04..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/BankAccount.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class BankAccount : ObfuscatedIdentifiable - { - [Attr] - public string Iban { get; set; } - - [HasMany] - public IList Cards { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/BankAccountsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/BankAccountsController.cs deleted file mode 100644 index c7751fde1a..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/BankAccountsController.cs +++ /dev/null @@ -1,14 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation -{ - public sealed class BankAccountsController : ObfuscatedIdentifiableController - { - public BankAccountsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/DebitCard.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/DebitCard.cs deleted file mode 100644 index 2c0a12fd30..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/DebitCard.cs +++ /dev/null @@ -1,18 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class DebitCard : ObfuscatedIdentifiable - { - [Attr] - public string OwnerName { get; set; } - - [Attr] - public short PinCode { get; set; } - - [HasOne] - public BankAccount Account { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/DebitCardsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/DebitCardsController.cs deleted file mode 100644 index d17fb2b016..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/DebitCardsController.cs +++ /dev/null @@ -1,14 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation -{ - public sealed class DebitCardsController : ObfuscatedIdentifiableController - { - public DebitCardsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs deleted file mode 100644 index a84be616b1..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/HexadecimalCodec.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.Net; -using System.Text; -using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation -{ - internal sealed class HexadecimalCodec - { - public int Decode(string value) - { - if (value == null) - { - return 0; - } - - if (!value.StartsWith("x", StringComparison.Ordinal)) - { - throw new JsonApiException(new Error(HttpStatusCode.BadRequest) - { - Title = "Invalid ID value.", - Detail = $"The value '{value}' is not a valid hexadecimal value." - }); - } - - string stringValue = FromHexString(value.Substring(1)); - return int.Parse(stringValue); - } - - private static string FromHexString(string hexString) - { - var bytes = new List(hexString.Length / 2); - - for (int index = 0; index < hexString.Length; index += 2) - { - string hexChar = hexString.Substring(index, 2); - byte bt = byte.Parse(hexChar, NumberStyles.HexNumber); - bytes.Add(bt); - } - - char[] chars = Encoding.ASCII.GetChars(bytes.ToArray()); - return new string(chars); - } - - public string Encode(int value) - { - if (value == 0) - { - return null; - } - - string stringValue = value.ToString(); - return 'x' + ToHexString(stringValue); - } - - private static string ToHexString(string value) - { - var builder = new StringBuilder(); - - foreach (byte bt in Encoding.ASCII.GetBytes(value)) - { - builder.Append(bt.ToString("X2")); - } - - return builder.ToString(); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs deleted file mode 100644 index f6703e7272..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/IdObfuscationTests.cs +++ /dev/null @@ -1,475 +0,0 @@ -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.EntityFrameworkCore; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation -{ - public sealed class IdObfuscationTests : IClassFixture, ObfuscationDbContext>> - { - private readonly ExampleIntegrationTestContext, ObfuscationDbContext> _testContext; - private readonly ObfuscationFakers _fakers = new ObfuscationFakers(); - - public IdObfuscationTests(ExampleIntegrationTestContext, ObfuscationDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - testContext.UseController(); - } - - [Fact] - public async Task Can_filter_equality_in_primary_resources() - { - // Arrange - List accounts = _fakers.BankAccount.Generate(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.BankAccounts.AddRange(accounts); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/bankAccounts?filter=equals(id,'{accounts[1].StringId}')"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Id.Should().Be(accounts[1].StringId); - } - - [Fact] - public async Task Can_filter_any_in_primary_resources() - { - // Arrange - List accounts = _fakers.BankAccount.Generate(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.BankAccounts.AddRange(accounts); - await dbContext.SaveChangesAsync(); - }); - - var codec = new HexadecimalCodec(); - string route = $"/bankAccounts?filter=any(id,'{accounts[1].StringId}','{codec.Encode(99999999)}')"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Id.Should().Be(accounts[1].StringId); - } - - [Fact] - public async Task Cannot_get_primary_resource_for_invalid_ID() - { - // Arrange - const string route = "/bankAccounts/not-a-hex-value"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.BadRequest); - error.Title.Should().Be("Invalid ID value."); - error.Detail.Should().Be("The value 'not-a-hex-value' is not a valid hexadecimal value."); - } - - [Fact] - public async Task Can_get_primary_resource_by_ID() - { - // Arrange - DebitCard card = _fakers.DebitCard.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.DebitCards.Add(card); - await dbContext.SaveChangesAsync(); - }); - - string route = "/debitCards/" + card.StringId; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Id.Should().Be(card.StringId); - } - - [Fact] - public async Task Can_get_secondary_resources() - { - // Arrange - BankAccount account = _fakers.BankAccount.Generate(); - account.Cards = _fakers.DebitCard.Generate(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.BankAccounts.Add(account); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/bankAccounts/{account.StringId}/cards"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(2); - responseDocument.ManyData[0].Id.Should().Be(account.Cards[0].StringId); - responseDocument.ManyData[1].Id.Should().Be(account.Cards[1].StringId); - } - - [Fact] - public async Task Can_include_resource_with_sparse_fieldset() - { - // Arrange - BankAccount account = _fakers.BankAccount.Generate(); - account.Cards = _fakers.DebitCard.Generate(1); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.BankAccounts.Add(account); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/bankAccounts/{account.StringId}?include=cards&fields[debitCards]=ownerName"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Id.Should().Be(account.StringId); - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Id.Should().Be(account.Cards[0].StringId); - responseDocument.Included[0].Attributes.Should().HaveCount(1); - responseDocument.Included[0].Relationships.Should().BeNull(); - } - - [Fact] - public async Task Can_get_relationship() - { - // Arrange - BankAccount account = _fakers.BankAccount.Generate(); - account.Cards = _fakers.DebitCard.Generate(1); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.BankAccounts.Add(account); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/bankAccounts/{account.StringId}/relationships/cards"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Id.Should().Be(account.Cards[0].StringId); - } - - [Fact] - public async Task Can_create_resource_with_relationship() - { - // Arrange - BankAccount existingAccount = _fakers.BankAccount.Generate(); - DebitCard newCard = _fakers.DebitCard.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.BankAccounts.Add(existingAccount); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "debitCards", - attributes = new - { - ownerName = newCard.OwnerName, - pinCode = newCard.PinCode - }, - relationships = new - { - account = new - { - data = new - { - type = "bankAccounts", - id = existingAccount.StringId - } - } - } - } - }; - - const string route = "/debitCards"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Attributes["ownerName"].Should().Be(newCard.OwnerName); - responseDocument.SingleData.Attributes["pinCode"].Should().Be(newCard.PinCode); - - var codec = new HexadecimalCodec(); - int newCardId = codec.Decode(responseDocument.SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - DebitCard cardInDatabase = await dbContext.DebitCards.Include(card => card.Account).FirstWithIdAsync(newCardId); - - cardInDatabase.OwnerName.Should().Be(newCard.OwnerName); - cardInDatabase.PinCode.Should().Be(newCard.PinCode); - - cardInDatabase.Account.Should().NotBeNull(); - cardInDatabase.Account.Id.Should().Be(existingAccount.Id); - cardInDatabase.Account.StringId.Should().Be(existingAccount.StringId); - }); - } - - [Fact] - public async Task Can_update_resource_with_relationship() - { - // Arrange - BankAccount existingAccount = _fakers.BankAccount.Generate(); - existingAccount.Cards = _fakers.DebitCard.Generate(1); - - DebitCard existingCard = _fakers.DebitCard.Generate(); - - string newIban = _fakers.BankAccount.Generate().Iban; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingAccount, existingCard); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "bankAccounts", - id = existingAccount.StringId, - attributes = new - { - iban = newIban - }, - relationships = new - { - cards = new - { - data = new[] - { - new - { - type = "debitCards", - id = existingCard.StringId - } - } - } - } - } - }; - - string route = "/bankAccounts/" + existingAccount.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - BankAccount accountInDatabase = await dbContext.BankAccounts.Include(account => account.Cards).FirstWithIdAsync(existingAccount.Id); - - accountInDatabase.Iban.Should().Be(newIban); - - accountInDatabase.Cards.Should().HaveCount(1); - accountInDatabase.Cards[0].Id.Should().Be(existingCard.Id); - accountInDatabase.Cards[0].StringId.Should().Be(existingCard.StringId); - }); - } - - [Fact] - public async Task Can_add_to_ToMany_relationship() - { - // Arrange - BankAccount existingAccount = _fakers.BankAccount.Generate(); - existingAccount.Cards = _fakers.DebitCard.Generate(1); - - DebitCard existingDebitCard = _fakers.DebitCard.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingAccount, existingDebitCard); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "debitCards", - id = existingDebitCard.StringId - } - } - }; - - string route = $"/bankAccounts/{existingAccount.StringId}/relationships/cards"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - BankAccount accountInDatabase = await dbContext.BankAccounts.Include(account => account.Cards).FirstWithIdAsync(existingAccount.Id); - - accountInDatabase.Cards.Should().HaveCount(2); - }); - } - - [Fact] - public async Task Can_remove_from_ToMany_relationship() - { - // Arrange - BankAccount existingAccount = _fakers.BankAccount.Generate(); - existingAccount.Cards = _fakers.DebitCard.Generate(2); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.BankAccounts.Add(existingAccount); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "debitCards", - id = existingAccount.Cards[0].StringId - } - } - }; - - string route = $"/bankAccounts/{existingAccount.StringId}/relationships/cards"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - BankAccount accountInDatabase = await dbContext.BankAccounts.Include(account => account.Cards).FirstWithIdAsync(existingAccount.Id); - - accountInDatabase.Cards.Should().HaveCount(1); - }); - } - - [Fact] - public async Task Can_delete_resource() - { - // Arrange - BankAccount existingAccount = _fakers.BankAccount.Generate(); - existingAccount.Cards = _fakers.DebitCard.Generate(1); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.BankAccounts.Add(existingAccount); - await dbContext.SaveChangesAsync(); - }); - - string route = "/bankAccounts/" + existingAccount.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - BankAccount accountInDatabase = await dbContext.BankAccounts.Include(account => account.Cards).FirstWithIdOrDefaultAsync(existingAccount.Id); - - accountInDatabase.Should().BeNull(); - }); - } - - [Fact] - public async Task Cannot_delete_missing_resource() - { - // Arrange - var codec = new HexadecimalCodec(); - string stringId = codec.Encode(99999999); - - string route = "/bankAccounts/" + stringId; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteDeleteAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 'bankAccounts' with ID '{stringId}' does not exist."); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiable.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiable.cs deleted file mode 100644 index e0e728a938..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiable.cs +++ /dev/null @@ -1,19 +0,0 @@ -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation -{ - public abstract class ObfuscatedIdentifiable : Identifiable - { - private static readonly HexadecimalCodec Codec = new HexadecimalCodec(); - - protected override string GetStringId(int value) - { - return Codec.Encode(value); - } - - protected override int GetTypedId(string value) - { - return Codec.Decode(value); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiableController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiableController.cs deleted file mode 100644 index ab4a02a70f..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscatedIdentifiableController.cs +++ /dev/null @@ -1,94 +0,0 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Services; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation -{ - public abstract class ObfuscatedIdentifiableController : BaseJsonApiController - where TResource : class, IIdentifiable - { - private readonly HexadecimalCodec _codec = new HexadecimalCodec(); - - protected ObfuscatedIdentifiableController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - - [HttpGet] - public override Task GetAsync(CancellationToken cancellationToken) - { - return base.GetAsync(cancellationToken); - } - - [HttpGet("{id}")] - public Task GetAsync(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) - { - int idValue = _codec.Decode(id); - return base.GetSecondaryAsync(idValue, relationshipName, cancellationToken); - } - - [HttpGet("{id}/relationships/{relationshipName}")] - public Task GetRelationshipAsync(string id, 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) - { - return base.PostAsync(resource, cancellationToken); - } - - [HttpPost("{id}/relationships/{relationshipName}")] - public Task PostRelationshipAsync(string id, string relationshipName, [FromBody] ISet secondaryResourceIds, - CancellationToken cancellationToken) - { - int idValue = _codec.Decode(id); - return base.PostRelationshipAsync(idValue, relationshipName, secondaryResourceIds, cancellationToken); - } - - [HttpPatch("{id}")] - public Task PatchAsync(string id, [FromBody] 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 secondaryResourceIds, - CancellationToken cancellationToken) - { - int idValue = _codec.Decode(id); - return base.PatchRelationshipAsync(idValue, relationshipName, secondaryResourceIds, cancellationToken); - } - - [HttpDelete("{id}")] - public Task DeleteAsync(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 secondaryResourceIds, - CancellationToken cancellationToken) - { - int idValue = _codec.Decode(id); - return base.DeleteRelationshipAsync(idValue, relationshipName, secondaryResourceIds, cancellationToken); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscationDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscationDbContext.cs deleted file mode 100644 index 939bae05bf..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscationDbContext.cs +++ /dev/null @@ -1,17 +0,0 @@ -using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class ObfuscationDbContext : DbContext - { - public DbSet BankAccounts { get; set; } - public DbSet DebitCards { get; set; } - - public ObfuscationDbContext(DbContextOptions options) - : base(options) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscationFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscationFakers.cs deleted file mode 100644 index ed064f2956..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/IdObfuscation/ObfuscationFakers.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using Bogus; -using TestBuildingBlocks; - -// @formatter:wrap_chained_method_calls chop_always -// @formatter:keep_existing_linebreaks true - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.IdObfuscation -{ - internal sealed class ObfuscationFakers : FakerContainer - { - private readonly Lazy> _lazyBankAccountFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(bankAccount => bankAccount.Iban, faker => faker.Finance.Iban())); - - private readonly Lazy> _lazyDebitCardFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(debitCard => debitCard.OwnerName, faker => faker.Name.FullName()) - .RuleFor(debitCard => debitCard.PinCode, faker => (short)faker.Random.Number(1000, 9999))); - - public Faker BankAccount => _lazyBankAccountFaker.Value; - public Faker DebitCard => _lazyDebitCardFaker.Value; - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/ModelStateDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/ModelStateDbContext.cs deleted file mode 100644 index 0805cc34f9..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/ModelStateDbContext.cs +++ /dev/null @@ -1,34 +0,0 @@ -using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore; - -// @formatter:wrap_chained_method_calls chop_always - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.ModelState -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class ModelStateDbContext : DbContext - { - public DbSet Directories { get; set; } - public DbSet Files { get; set; } - - public ModelStateDbContext(DbContextOptions options) - : base(options) - { - } - - protected override void OnModelCreating(ModelBuilder builder) - { - builder.Entity() - .HasMany(systemDirectory => systemDirectory.Subdirectories) - .WithOne(systemDirectory => systemDirectory.Parent); - - builder.Entity() - .HasOne(systemDirectory => systemDirectory.Self) - .WithOne(); - - builder.Entity() - .HasOne(systemDirectory => systemDirectory.AlsoSelf) - .WithOne(); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs deleted file mode 100644 index a59f88d766..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/ModelStateValidationTests.cs +++ /dev/null @@ -1,940 +0,0 @@ -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExampleTests.Startups; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.ModelState -{ - public sealed class ModelStateValidationTests - : IClassFixture, ModelStateDbContext>> - { - private readonly ExampleIntegrationTestContext, ModelStateDbContext> _testContext; - - public ModelStateValidationTests(ExampleIntegrationTestContext, ModelStateDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - testContext.UseController(); - } - - [Fact] - public async Task Cannot_create_resource_with_omitted_required_attribute() - { - // Arrange - var requestBody = new - { - data = new - { - type = "systemDirectories", - attributes = new - { - isCaseSensitive = true - } - } - }; - - const string route = "/systemDirectories"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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.Pointer.Should().Be("/data/attributes/name"); - } - - [Fact] - public async Task Cannot_create_resource_with_null_for_required_attribute_value() - { - // Arrange - var requestBody = new - { - data = new - { - type = "systemDirectories", - attributes = new - { - name = (string)null, - isCaseSensitive = true - } - } - }; - - const string route = "/systemDirectories"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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.Pointer.Should().Be("/data/attributes/name"); - } - - [Fact] - public async Task Cannot_create_resource_with_invalid_attribute_value() - { - // Arrange - var requestBody = new - { - data = new - { - type = "systemDirectories", - attributes = new - { - name = "!@#$%^&*().-", - isCaseSensitive = true - } - } - }; - - const string route = "/systemDirectories"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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.Pointer.Should().Be("/data/attributes/name"); - } - - [Fact] - public async Task Can_create_resource_with_valid_attribute_value() - { - // Arrange - var requestBody = new - { - data = new - { - type = "systemDirectories", - attributes = new - { - name = "Projects", - isCaseSensitive = true - } - } - }; - - const string route = "/systemDirectories"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["name"].Should().Be("Projects"); - responseDocument.SingleData.Attributes["isCaseSensitive"].Should().Be(true); - } - - [Fact] - public async Task Cannot_create_resource_with_multiple_violations() - { - // Arrange - var requestBody = new - { - data = new - { - type = "systemDirectories", - attributes = new - { - sizeInBytes = -1 - } - } - }; - - const string route = "/systemDirectories"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(3); - - Error 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.Pointer.Should().Be("/data/attributes/name"); - - Error 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.Pointer.Should().Be("/data/attributes/sizeInBytes"); - - Error 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.Pointer.Should().Be("/data/attributes/isCaseSensitive"); - } - - [Fact] - public async Task Can_create_resource_with_annotated_relationships() - { - // Arrange - var parentDirectory = new SystemDirectory - { - Name = "Shared", - IsCaseSensitive = true - }; - - var subdirectory = new SystemDirectory - { - Name = "Open Source", - IsCaseSensitive = true - }; - - var file = new SystemFile - { - FileName = "Main.cs", - SizeInBytes = 100 - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Directories.AddRange(parentDirectory, subdirectory); - dbContext.Files.Add(file); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "systemDirectories", - attributes = new - { - name = "Projects", - isCaseSensitive = true - }, - relationships = new - { - subdirectories = new - { - data = new[] - { - new - { - type = "systemDirectories", - id = subdirectory.StringId - } - } - }, - files = new - { - data = new[] - { - new - { - type = "systemFiles", - id = file.StringId - } - } - }, - parent = new - { - data = new - { - type = "systemDirectories", - id = parentDirectory.StringId - } - } - } - } - }; - - const string route = "/systemDirectories"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["name"].Should().Be("Projects"); - responseDocument.SingleData.Attributes["isCaseSensitive"].Should().Be(true); - } - - [Fact] - public async Task Can_add_to_annotated_ToMany_relationship() - { - // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = true - }; - - var file = new SystemFile - { - FileName = "Main.cs", - SizeInBytes = 100 - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(directory, file); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "systemFiles", - id = file.StringId - } - } - }; - - string route = $"/systemDirectories/{directory.StringId}/relationships/files"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - } - - [Fact] - public async Task Can_update_resource_with_omitted_required_attribute_value() - { - // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = true - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Directories.Add(directory); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "systemDirectories", - id = directory.StringId, - attributes = new - { - sizeInBytes = 100 - } - } - }; - - string route = "/systemDirectories/" + directory.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - } - - [Fact] - public async Task Cannot_update_resource_with_null_for_required_attribute_value() - { - // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = true - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Directories.Add(directory); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "systemDirectories", - id = directory.StringId, - attributes = new - { - name = (string)null - } - } - }; - - string route = "/systemDirectories/" + directory.StringId; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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.Pointer.Should().Be("/data/attributes/name"); - } - - [Fact] - public async Task Cannot_update_resource_with_invalid_attribute_value() - { - // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = true - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Directories.Add(directory); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "systemDirectories", - id = directory.StringId, - attributes = new - { - name = "!@#$%^&*().-" - } - } - }; - - string route = "/systemDirectories/" + directory.StringId; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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.Pointer.Should().Be("/data/attributes/name"); - } - - [Fact] - public async Task Cannot_update_resource_with_invalid_ID() - { - // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = true - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Directories.Add(directory); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "systemDirectories", - id = -1, - attributes = new - { - name = "Repositories" - }, - relationships = new - { - subdirectories = new - { - data = new[] - { - new - { - type = "systemDirectories", - id = -1 - } - } - } - } - } - }; - - const string route = "/systemDirectories/-1"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(2); - - Error 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.Pointer.Should().Be("/data/attributes/id"); - - Error 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.Pointer.Should().Be("/data/attributes/Subdirectories[0].Id"); - } - - [Fact] - public async Task Can_update_resource_with_valid_attribute_value() - { - // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = true - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Directories.Add(directory); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "systemDirectories", - id = directory.StringId, - attributes = new - { - name = "Repositories" - } - } - }; - - string route = "/systemDirectories/" + directory.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - } - - [Fact] - public async Task Can_update_resource_with_annotated_relationships() - { - // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = false, - Subdirectories = new List - { - new SystemDirectory - { - Name = "C#", - IsCaseSensitive = false - } - }, - Files = new List - { - new SystemFile - { - FileName = "readme.txt" - } - }, - Parent = new SystemDirectory - { - Name = "Data", - IsCaseSensitive = false - } - }; - - var otherParent = new SystemDirectory - { - Name = "Shared", - IsCaseSensitive = false - }; - - var otherSubdirectory = new SystemDirectory - { - Name = "Shared", - IsCaseSensitive = false - }; - - var otherFile = new SystemFile - { - FileName = "readme.md" - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Directories.AddRange(directory, otherParent, otherSubdirectory); - dbContext.Files.Add(otherFile); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "systemDirectories", - id = directory.StringId, - attributes = new - { - name = "Project Files" - }, - relationships = new - { - subdirectories = new - { - data = new[] - { - new - { - type = "systemDirectories", - id = otherSubdirectory.StringId - } - } - }, - files = new - { - data = new[] - { - new - { - type = "systemFiles", - id = otherFile.StringId - } - } - }, - parent = new - { - data = new - { - type = "systemDirectories", - id = otherParent.StringId - } - } - } - } - }; - - string route = "/systemDirectories/" + directory.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - } - - [Fact] - public async Task Can_update_resource_with_multiple_self_references() - { - // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = false - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Directories.Add(directory); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "systemDirectories", - id = directory.StringId, - attributes = new - { - name = "Project files" - }, - relationships = new - { - self = new - { - data = new - { - type = "systemDirectories", - id = directory.StringId - } - }, - alsoSelf = new - { - data = new - { - type = "systemDirectories", - id = directory.StringId - } - } - } - } - }; - - string route = "/systemDirectories/" + directory.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - } - - [Fact] - public async Task Can_update_resource_with_collection_of_self_references() - { - // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = false - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Directories.Add(directory); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "systemDirectories", - id = directory.StringId, - attributes = new - { - name = "Project files" - }, - relationships = new - { - subdirectories = new - { - data = new[] - { - new - { - type = "systemDirectories", - id = directory.StringId - } - } - } - } - } - }; - - string route = "/systemDirectories/" + directory.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - } - - [Fact] - public async Task Can_replace_annotated_ToOne_relationship() - { - // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = true, - Parent = new SystemDirectory - { - Name = "Data", - IsCaseSensitive = true - } - }; - - var otherParent = new SystemDirectory - { - Name = "Data files", - IsCaseSensitive = true - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Directories.AddRange(directory, otherParent); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "systemDirectories", - id = otherParent.StringId - } - }; - - string route = "/systemDirectories/" + directory.StringId + "/relationships/parent"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - } - - [Fact] - public async Task Can_replace_annotated_ToMany_relationship() - { - // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = true, - Files = new List - { - new SystemFile - { - FileName = "Main.cs" - }, - new SystemFile - { - FileName = "Program.cs" - } - } - }; - - var otherFile = new SystemFile - { - FileName = "EntryPoint.cs" - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Directories.Add(directory); - dbContext.Files.Add(otherFile); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "systemFiles", - id = otherFile.StringId - } - } - }; - - string route = "/systemDirectories/" + directory.StringId + "/relationships/files"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - } - - [Fact] - public async Task Can_remove_from_annotated_ToMany_relationship() - { - // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = true, - Files = new List - { - new SystemFile - { - FileName = "Main.cs", - SizeInBytes = 100 - } - } - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(directory); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new object[0] - }; - - string route = $"/systemDirectories/{directory.StringId}/relationships/files"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs deleted file mode 100644 index 7157e70491..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/NoModelStateValidationTests.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExampleTests.Startups; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.ModelState -{ - public sealed class NoModelStateValidationTests : IClassFixture, ModelStateDbContext>> - { - private readonly ExampleIntegrationTestContext, ModelStateDbContext> _testContext; - - public NoModelStateValidationTests(ExampleIntegrationTestContext, ModelStateDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - testContext.UseController(); - } - - [Fact] - public async Task Can_create_resource_with_invalid_attribute_value() - { - // Arrange - var requestBody = new - { - data = new - { - type = "systemDirectories", - attributes = new - { - name = "!@#$%^&*().-", - isCaseSensitive = "false" - } - } - }; - - const string route = "/systemDirectories"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["name"].Should().Be("!@#$%^&*().-"); - } - - [Fact] - public async Task Can_update_resource_with_invalid_attribute_value() - { - // Arrange - var directory = new SystemDirectory - { - Name = "Projects", - IsCaseSensitive = false - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Directories.Add(directory); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "systemDirectories", - id = directory.StringId, - attributes = new - { - name = "!@#$%^&*().-" - } - } - }; - - string route = "/systemDirectories/" + directory.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemDirectoriesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemDirectoriesController.cs deleted file mode 100644 index 639c319d7c..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemDirectoriesController.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.ModelState -{ - public sealed class SystemDirectoriesController : JsonApiController - { - public SystemDirectoriesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemDirectory.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemDirectory.cs deleted file mode 100644 index 4504dd6e13..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemDirectory.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.ModelState -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class SystemDirectory : Identifiable - { - [Required] - [RegularExpression("^[0-9]+$")] - public override int Id { get; set; } - - [Attr] - [Required] - [RegularExpression(@"^[\w\s]+$")] - public string Name { get; set; } - - [Attr] - [Required] - public bool? IsCaseSensitive { get; set; } - - [Attr] - [Range(typeof(long), "0", "9223372036854775807")] - public long SizeInBytes { get; set; } - - [HasMany] - public ICollection Subdirectories { get; set; } - - [HasMany] - public ICollection Files { get; set; } - - [HasOne] - public SystemDirectory Self { get; set; } - - [HasOne] - public SystemDirectory AlsoSelf { get; set; } - - [HasOne] - public SystemDirectory Parent { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs deleted file mode 100644 index 1a66473c8d..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemFile.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.ModelState -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class SystemFile : Identifiable - { - [Attr] - [Required] - [MinLength(1)] - public string FileName { get; set; } - - [Attr] - [Required] - [Range(typeof(long), "0", "9223372036854775807")] - public long SizeInBytes { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemFilesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemFilesController.cs deleted file mode 100644 index 425445b6ad..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/ModelState/SystemFilesController.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.ModelState -{ - public sealed class SystemFilesController : JsonApiController - { - public SystemFilesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/Workflow.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/Workflow.cs deleted file mode 100644 index 1cd1e3b783..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/Workflow.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.RequestBody -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Workflow : Identifiable - { - [Attr] - public WorkflowStage Stage { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowDbContext.cs deleted file mode 100644 index a54ac87406..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowDbContext.cs +++ /dev/null @@ -1,18 +0,0 @@ -using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore; - -// @formatter:wrap_chained_method_calls chop_always - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.RequestBody -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class WorkflowDbContext : DbContext - { - public DbSet Workflows { get; set; } - - public WorkflowDbContext(DbContextOptions options) - : base(options) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowDefinition.cs deleted file mode 100644 index 7c0b59c53b..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowDefinition.cs +++ /dev/null @@ -1,115 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net; -using System.Threading; -using System.Threading.Tasks; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Objects; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.RequestBody -{ - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class WorkflowDefinition : JsonApiResourceDefinition - { - private static readonly Dictionary> StageTransitionTable = - new Dictionary> - { - [WorkflowStage.Created] = new[] - { - WorkflowStage.InProgress - }, - [WorkflowStage.InProgress] = new[] - { - WorkflowStage.OnHold, - WorkflowStage.Succeeded, - WorkflowStage.Failed, - WorkflowStage.Canceled - }, - [WorkflowStage.OnHold] = new[] - { - WorkflowStage.InProgress, - WorkflowStage.Canceled - } - }; - - private WorkflowStage _previousStage; - - public WorkflowDefinition(IResourceGraph resourceGraph) - : base(resourceGraph) - { - } - - public override Task OnPrepareWriteAsync(Workflow resource, OperationKind operationKind, CancellationToken cancellationToken) - { - if (operationKind == OperationKind.UpdateResource) - { - _previousStage = resource.Stage; - } - - return Task.CompletedTask; - } - - public override Task OnWritingAsync(Workflow resource, OperationKind operationKind, CancellationToken cancellationToken) - { - if (operationKind == OperationKind.CreateResource) - { - AssertHasValidInitialStage(resource); - } - else if (operationKind == OperationKind.UpdateResource && resource.Stage != _previousStage) - { - AssertCanTransitionToStage(_previousStage, resource.Stage); - } - - return Task.CompletedTask; - } - - [AssertionMethod] - private static void AssertHasValidInitialStage(Workflow resource) - { - if (resource.Stage != WorkflowStage.Created) - { - throw new JsonApiException(new Error(HttpStatusCode.UnprocessableEntity) - { - Title = "Invalid workflow stage.", - Detail = $"Initial stage of workflow must be '{WorkflowStage.Created}'.", - Source = - { - Pointer = "/data/attributes/stage" - } - }); - } - } - - [AssertionMethod] - private static void AssertCanTransitionToStage(WorkflowStage fromStage, WorkflowStage toStage) - { - if (!CanTransitionToStage(fromStage, toStage)) - { - throw new JsonApiException(new Error(HttpStatusCode.UnprocessableEntity) - { - Title = "Invalid workflow stage.", - Detail = $"Cannot transition from '{fromStage}' to '{toStage}'.", - Source = - { - Pointer = "/data/attributes/stage" - } - }); - } - } - - private static bool CanTransitionToStage(WorkflowStage fromStage, WorkflowStage toStage) - { - if (StageTransitionTable.ContainsKey(fromStage)) - { - ICollection possibleNextStages = StageTransitionTable[fromStage]; - return possibleNextStages.Contains(toStage); - } - - return false; - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowStage.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowStage.cs deleted file mode 100644 index 67602b4483..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowStage.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.RequestBody -{ - public enum WorkflowStage - { - Created, - InProgress, - OnHold, - Succeeded, - Failed, - Canceled - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs deleted file mode 100644 index ec6bfbdeff..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowTests.cs +++ /dev/null @@ -1,173 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExampleTests.Startups; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.RequestBody -{ - public sealed class WorkflowTests : IClassFixture, WorkflowDbContext>> - { - private readonly ExampleIntegrationTestContext, WorkflowDbContext> _testContext; - - public WorkflowTests(ExampleIntegrationTestContext, WorkflowDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - - testContext.ConfigureServicesAfterStartup(services => - { - services.AddResourceDefinition(); - }); - } - - [Fact] - public async Task Can_create_in_valid_stage() - { - // Arrange - var requestBody = new - { - data = new - { - type = "workflows", - attributes = new - { - stage = WorkflowStage.Created - } - } - }; - - const string route = "/workflows"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Should().NotBeNull(); - } - - [Fact] - public async Task Cannot_create_in_invalid_stage() - { - // Arrange - var requestBody = new - { - data = new - { - type = "workflows", - attributes = new - { - stage = WorkflowStage.Canceled - } - } - }; - - const string route = "/workflows"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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.Pointer.Should().Be("/data/attributes/stage"); - } - - [Fact] - public async Task Cannot_transition_to_invalid_stage() - { - // Arrange - var existingWorkflow = new Workflow - { - Stage = WorkflowStage.OnHold - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Workflows.Add(existingWorkflow); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "workflows", - id = existingWorkflow.StringId, - attributes = new - { - stage = WorkflowStage.Succeeded - } - } - }; - - string route = "/workflows/" + existingWorkflow.StringId; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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.Pointer.Should().Be("/data/attributes/stage"); - } - - [Fact] - public async Task Can_transition_to_valid_stage() - { - // Arrange - var existingWorkflow = new Workflow - { - Stage = WorkflowStage.InProgress - }; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Workflows.Add(existingWorkflow); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "workflows", - id = existingWorkflow.StringId, - attributes = new - { - stage = WorkflowStage.Failed - } - } - }; - - string route = "/workflows/" + existingWorkflow.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowsController.cs deleted file mode 100644 index 8edd74c51c..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/InputValidation/RequestBody/WorkflowsController.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.InputValidation.RequestBody -{ - public sealed class WorkflowsController : JsonApiController - { - public WorkflowsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs deleted file mode 100644 index c54d868a17..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs +++ /dev/null @@ -1,381 +0,0 @@ -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links -{ - public sealed class AbsoluteLinksWithNamespaceTests - : IClassFixture, LinksDbContext>> - { - private const string HostPrefix = "http://localhost"; - - private readonly ExampleIntegrationTestContext, LinksDbContext> _testContext; - private readonly LinksFakers _fakers = new LinksFakers(); - - public AbsoluteLinksWithNamespaceTests(ExampleIntegrationTestContext, LinksDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - testContext.UseController(); - - testContext.ConfigureServicesAfterStartup(services => - { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); - - var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); - options.IncludeTotalResourceCount = true; - } - - [Fact] - public async Task Get_primary_resource_by_ID_returns_absolute_links() - { - // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.PhotoAlbums.Add(album); - await dbContext.SaveChangesAsync(); - }); - - string route = "/api/photoAlbums/" + album.StringId; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - 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.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Links.Self.Should().Be(HostPrefix + route); - responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be(HostPrefix + route + "/relationships/photos"); - responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be(HostPrefix + route + "/photos"); - } - - [Fact] - public async Task Get_primary_resources_with_include_returns_absolute_links() - { - // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.PhotoAlbums.Add(album); - await dbContext.SaveChangesAsync(); - }); - - const string route = "/api/photoAlbums?include=photos"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Links.Self.Should().Be(HostPrefix + route); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be(HostPrefix + route); - responseDocument.Links.Last.Should().Be(HostPrefix + route); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); - - string albumLink = HostPrefix + $"/api/photoAlbums/{album.StringId}"; - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Links.Self.Should().Be(albumLink); - responseDocument.ManyData[0].Relationships["photos"].Links.Self.Should().Be(albumLink + "/relationships/photos"); - responseDocument.ManyData[0].Relationships["photos"].Links.Related.Should().Be(albumLink + "/photos"); - - string photoLink = HostPrefix + $"/api/photos/{album.Photos.ElementAt(0).StringId}"; - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(photoLink); - responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be(photoLink + "/relationships/album"); - responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be(photoLink + "/album"); - } - - [Fact] - public async Task Get_secondary_resource_returns_absolute_links() - { - // Arrange - Photo photo = _fakers.Photo.Generate(); - photo.Album = _fakers.PhotoAlbum.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Photos.Add(photo); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/api/photos/{photo.StringId}/album"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - 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(); - - string albumLink = HostPrefix + $"/api/photoAlbums/{photo.Album.StringId}"; - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Links.Self.Should().Be(albumLink); - responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be(albumLink + "/relationships/photos"); - responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be(albumLink + "/photos"); - } - - [Fact] - public async Task Get_secondary_resources_returns_absolute_links() - { - // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.PhotoAlbums.Add(album); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/api/photoAlbums/{album.StringId}/photos"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Links.Self.Should().Be(HostPrefix + route); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be(HostPrefix + route); - responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); - - string photoLink = HostPrefix + $"/api/photos/{album.Photos.ElementAt(0).StringId}"; - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Links.Self.Should().Be(photoLink); - responseDocument.ManyData[0].Relationships["album"].Links.Self.Should().Be(photoLink + "/relationships/album"); - responseDocument.ManyData[0].Relationships["album"].Links.Related.Should().Be(photoLink + "/album"); - } - - [Fact] - public async Task Get_HasOne_relationship_returns_absolute_links() - { - // Arrange - Photo photo = _fakers.Photo.Generate(); - photo.Album = _fakers.PhotoAlbum.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Photos.Add(photo); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/api/photos/{photo.StringId}/relationships/album"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Links.Self.Should().Be(HostPrefix + route); - responseDocument.Links.Related.Should().Be(HostPrefix + $"/api/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.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Links.Should().BeNull(); - responseDocument.SingleData.Relationships.Should().BeNull(); - } - - [Fact] - public async Task Get_HasMany_relationship_returns_absolute_links() - { - // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.PhotoAlbums.Add(album); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/api/photoAlbums/{album.StringId}/relationships/photos"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Links.Self.Should().Be(HostPrefix + route); - responseDocument.Links.Related.Should().Be(HostPrefix + $"/api/photoAlbums/{album.StringId}/photos"); - responseDocument.Links.First.Should().Be(HostPrefix + route); - responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Links.Should().BeNull(); - responseDocument.ManyData[0].Relationships.Should().BeNull(); - } - - [Fact] - public async Task Create_resource_with_side_effects_and_include_returns_absolute_links() - { - // Arrange - Photo existingPhoto = _fakers.Photo.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Photos.Add(existingPhoto); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "photoAlbums", - relationships = new - { - photos = new - { - data = new[] - { - new - { - type = "photos", - id = existingPhoto.StringId - } - } - } - } - } - }; - - const string route = "/api/photoAlbums?include=photos"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - 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(); - - string albumLink = HostPrefix + $"/api/photoAlbums/{responseDocument.SingleData.Id}"; - - responseDocument.SingleData.Links.Self.Should().Be(albumLink); - responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be(albumLink + "/relationships/photos"); - responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be(albumLink + "/photos"); - - string photoLink = HostPrefix + $"/api/photos/{existingPhoto.StringId}"; - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(photoLink); - responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be(photoLink + "/relationships/album"); - responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be(photoLink + "/album"); - } - - [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(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingPhoto, existingAlbum); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "photos", - id = existingPhoto.StringId, - relationships = new - { - album = new - { - data = new - { - type = "photoAlbums", - id = existingAlbum.StringId - } - } - } - } - }; - - string route = $"/api/photos/{existingPhoto.StringId}?include=album"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - 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(); - - string photoLink = HostPrefix + $"/api/photos/{existingPhoto.StringId}"; - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Links.Self.Should().Be(photoLink); - responseDocument.SingleData.Relationships["album"].Links.Self.Should().Be(photoLink + "/relationships/album"); - responseDocument.SingleData.Relationships["album"].Links.Related.Should().Be(photoLink + "/album"); - - string albumLink = HostPrefix + $"/api/photoAlbums/{existingAlbum.StringId}"; - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(albumLink); - responseDocument.Included[0].Relationships["photos"].Links.Self.Should().Be(albumLink + "/relationships/photos"); - responseDocument.Included[0].Relationships["photos"].Links.Related.Should().Be(albumLink + "/photos"); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs deleted file mode 100644 index db26b78f09..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs +++ /dev/null @@ -1,381 +0,0 @@ -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links -{ - public sealed class AbsoluteLinksWithoutNamespaceTests - : IClassFixture, LinksDbContext>> - { - private const string HostPrefix = "http://localhost"; - - private readonly ExampleIntegrationTestContext, LinksDbContext> _testContext; - private readonly LinksFakers _fakers = new LinksFakers(); - - public AbsoluteLinksWithoutNamespaceTests(ExampleIntegrationTestContext, LinksDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - testContext.UseController(); - - testContext.ConfigureServicesAfterStartup(services => - { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); - - var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); - options.IncludeTotalResourceCount = true; - } - - [Fact] - public async Task Get_primary_resource_by_ID_returns_absolute_links() - { - // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.PhotoAlbums.Add(album); - await dbContext.SaveChangesAsync(); - }); - - string route = "/photoAlbums/" + album.StringId; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - 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.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Links.Self.Should().Be(HostPrefix + route); - responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be(HostPrefix + route + "/relationships/photos"); - responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be(HostPrefix + route + "/photos"); - } - - [Fact] - public async Task Get_primary_resources_with_include_returns_absolute_links() - { - // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.PhotoAlbums.Add(album); - await dbContext.SaveChangesAsync(); - }); - - const string route = "/photoAlbums?include=photos"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Links.Self.Should().Be(HostPrefix + route); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be(HostPrefix + route); - responseDocument.Links.Last.Should().Be(HostPrefix + route); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); - - string albumLink = HostPrefix + $"/photoAlbums/{album.StringId}"; - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Links.Self.Should().Be(albumLink); - responseDocument.ManyData[0].Relationships["photos"].Links.Self.Should().Be(albumLink + "/relationships/photos"); - responseDocument.ManyData[0].Relationships["photos"].Links.Related.Should().Be(albumLink + "/photos"); - - string photoLink = HostPrefix + $"/photos/{album.Photos.ElementAt(0).StringId}"; - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(photoLink); - responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be(photoLink + "/relationships/album"); - responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be(photoLink + "/album"); - } - - [Fact] - public async Task Get_secondary_resource_returns_absolute_links() - { - // Arrange - Photo photo = _fakers.Photo.Generate(); - photo.Album = _fakers.PhotoAlbum.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Photos.Add(photo); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/photos/{photo.StringId}/album"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - 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(); - - string albumLink = HostPrefix + $"/photoAlbums/{photo.Album.StringId}"; - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Links.Self.Should().Be(albumLink); - responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be(albumLink + "/relationships/photos"); - responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be(albumLink + "/photos"); - } - - [Fact] - public async Task Get_secondary_resources_returns_absolute_links() - { - // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.PhotoAlbums.Add(album); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/photoAlbums/{album.StringId}/photos"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Links.Self.Should().Be(HostPrefix + route); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be(HostPrefix + route); - responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); - - string photoLink = HostPrefix + $"/photos/{album.Photos.ElementAt(0).StringId}"; - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Links.Self.Should().Be(photoLink); - responseDocument.ManyData[0].Relationships["album"].Links.Self.Should().Be(photoLink + "/relationships/album"); - responseDocument.ManyData[0].Relationships["album"].Links.Related.Should().Be(photoLink + "/album"); - } - - [Fact] - public async Task Get_HasOne_relationship_returns_absolute_links() - { - // Arrange - Photo photo = _fakers.Photo.Generate(); - photo.Album = _fakers.PhotoAlbum.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Photos.Add(photo); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/photos/{photo.StringId}/relationships/album"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Links.Self.Should().Be(HostPrefix + route); - responseDocument.Links.Related.Should().Be(HostPrefix + $"/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.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Links.Should().BeNull(); - responseDocument.SingleData.Relationships.Should().BeNull(); - } - - [Fact] - public async Task Get_HasMany_relationship_returns_absolute_links() - { - // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.PhotoAlbums.Add(album); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/photoAlbums/{album.StringId}/relationships/photos"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Links.Self.Should().Be(HostPrefix + route); - responseDocument.Links.Related.Should().Be(HostPrefix + $"/photoAlbums/{album.StringId}/photos"); - responseDocument.Links.First.Should().Be(HostPrefix + route); - responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Links.Should().BeNull(); - responseDocument.ManyData[0].Relationships.Should().BeNull(); - } - - [Fact] - public async Task Create_resource_with_side_effects_and_include_returns_absolute_links() - { - // Arrange - Photo existingPhoto = _fakers.Photo.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Photos.Add(existingPhoto); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "photoAlbums", - relationships = new - { - photos = new - { - data = new[] - { - new - { - type = "photos", - id = existingPhoto.StringId - } - } - } - } - } - }; - - const string route = "/photoAlbums?include=photos"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - 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(); - - string albumLink = HostPrefix + $"/photoAlbums/{responseDocument.SingleData.Id}"; - - responseDocument.SingleData.Links.Self.Should().Be(albumLink); - responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be(albumLink + "/relationships/photos"); - responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be(albumLink + "/photos"); - - string photoLink = HostPrefix + $"/photos/{existingPhoto.StringId}"; - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(photoLink); - responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be(photoLink + "/relationships/album"); - responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be(photoLink + "/album"); - } - - [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(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingPhoto, existingAlbum); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "photos", - id = existingPhoto.StringId, - relationships = new - { - album = new - { - data = new - { - type = "photoAlbums", - id = existingAlbum.StringId - } - } - } - } - }; - - string route = $"/photos/{existingPhoto.StringId}?include=album"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - 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(); - - string photoLink = HostPrefix + $"/photos/{existingPhoto.StringId}"; - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Links.Self.Should().Be(photoLink); - responseDocument.SingleData.Relationships["album"].Links.Self.Should().Be(photoLink + "/relationships/album"); - responseDocument.SingleData.Relationships["album"].Links.Related.Should().Be(photoLink + "/album"); - - string albumLink = HostPrefix + $"/photoAlbums/{existingAlbum.StringId}"; - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(albumLink); - responseDocument.Included[0].Relationships["photos"].Links.Self.Should().Be(albumLink + "/relationships/photos"); - responseDocument.Included[0].Relationships["photos"].Links.Related.Should().Be(albumLink + "/photos"); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/LinkInclusionTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/LinkInclusionTests.cs deleted file mode 100644 index b47697dad7..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/LinkInclusionTests.cs +++ /dev/null @@ -1,96 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExampleTests.Startups; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links -{ - public sealed class LinkInclusionTests : IClassFixture, LinksDbContext>> - { - private readonly ExampleIntegrationTestContext, LinksDbContext> _testContext; - private readonly LinksFakers _fakers = new LinksFakers(); - - public LinkInclusionTests(ExampleIntegrationTestContext, LinksDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - testContext.UseController(); - } - - [Fact] - 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(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.PhotoLocations.Add(location); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/photoLocations/{location.StringId}?include=photo,album"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Links.Should().BeNull(); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Links.Should().BeNull(); - responseDocument.SingleData.Relationships["photo"].Links.Self.Should().BeNull(); - responseDocument.SingleData.Relationships["photo"].Links.Related.Should().NotBeNull(); - responseDocument.SingleData.Relationships["album"].Links.Should().BeNull(); - - responseDocument.Included.Should().HaveCount(2); - - responseDocument.Included[0].Links.Self.Should().NotBeNull(); - responseDocument.Included[0].Relationships["location"].Links.Self.Should().NotBeNull(); - responseDocument.Included[0].Relationships["location"].Links.Related.Should().NotBeNull(); - - responseDocument.Included[1].Links.Self.Should().NotBeNull(); - responseDocument.Included[1].Relationships["photos"].Links.Self.Should().NotBeNull(); - responseDocument.Included[1].Relationships["photos"].Links.Related.Should().NotBeNull(); - } - - [Fact] - public async Task Get_secondary_resource_applies_links_visibility_from_ResourceLinksAttribute() - { - // Arrange - Photo photo = _fakers.Photo.Generate(); - photo.Location = _fakers.PhotoLocation.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Photos.Add(photo); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/photos/{photo.StringId}/location"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Links.Should().BeNull(); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Links.Should().BeNull(); - responseDocument.SingleData.Relationships["photo"].Links.Self.Should().BeNull(); - responseDocument.SingleData.Relationships["photo"].Links.Related.Should().NotBeNull(); - responseDocument.SingleData.Relationships.Should().NotContainKey("album"); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/LinksDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/LinksDbContext.cs deleted file mode 100644 index bad61d1e3a..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/LinksDbContext.cs +++ /dev/null @@ -1,28 +0,0 @@ -using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore; - -// @formatter:wrap_chained_method_calls chop_always - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class LinksDbContext : DbContext - { - public DbSet PhotoAlbums { get; set; } - public DbSet Photos { get; set; } - public DbSet PhotoLocations { get; 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"); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/LinksFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/LinksFakers.cs deleted file mode 100644 index bd6ee4acb3..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/LinksFakers.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; -using Bogus; -using TestBuildingBlocks; - -// @formatter:wrap_chained_method_calls chop_always -// @formatter:keep_existing_linebreaks true - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links -{ - internal sealed class LinksFakers : FakerContainer - { - private readonly Lazy> _lazyPhotoAlbumFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(photoAlbum => photoAlbum.Name, faker => faker.Lorem.Sentence())); - - private readonly Lazy> _lazyPhotoFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(photo => photo.Url, faker => faker.Image.PlaceImgUrl())); - - private readonly Lazy> _lazyPhotoLocationFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(photoLocation => photoLocation.PlaceName, faker => faker.Address.FullAddress()) - .RuleFor(photoLocation => photoLocation.Latitude, faker => faker.Address.Latitude()) - .RuleFor(photoLocation => photoLocation.Longitude, faker => faker.Address.Longitude())); - - public Faker PhotoAlbum => _lazyPhotoAlbumFaker.Value; - public Faker Photo => _lazyPhotoFaker.Value; - public Faker PhotoLocation => _lazyPhotoLocationFaker.Value; - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/Photo.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/Photo.cs deleted file mode 100644 index e13d93fd92..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/Photo.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Photo : Identifiable - { - [Attr] - public string Url { get; set; } - - [HasOne] - public PhotoLocation Location { get; set; } - - [HasOne] - public PhotoAlbum Album { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotoAlbum.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotoAlbum.cs deleted file mode 100644 index 19ff317933..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotoAlbum.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class PhotoAlbum : Identifiable - { - [Attr] - public string Name { get; set; } - - [HasMany] - public ISet Photos { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotoAlbumsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotoAlbumsController.cs deleted file mode 100644 index bc40b8e1c0..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotoAlbumsController.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links -{ - public sealed class PhotoAlbumsController : JsonApiController - { - public PhotoAlbumsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotoLocation.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotoLocation.cs deleted file mode 100644 index feb6582694..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotoLocation.cs +++ /dev/null @@ -1,26 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links -{ - [ResourceLinks(TopLevelLinks = LinkTypes.None, ResourceLinks = LinkTypes.None, RelationshipLinks = LinkTypes.Related)] - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class PhotoLocation : Identifiable - { - [Attr] - public string PlaceName { get; set; } - - [Attr] - public double Latitude { get; set; } - - [Attr] - public double Longitude { get; set; } - - [HasOne] - public Photo Photo { get; set; } - - [HasOne(Links = LinkTypes.None)] - public PhotoAlbum Album { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotoLocationsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotoLocationsController.cs deleted file mode 100644 index 0da524de7f..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotoLocationsController.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links -{ - public sealed class PhotoLocationsController : JsonApiController - { - public PhotoLocationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotosController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotosController.cs deleted file mode 100644 index b7b2660b6d..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/PhotosController.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links -{ - public sealed class PhotosController : JsonApiController - { - public PhotosController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs deleted file mode 100644 index e77685caec..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs +++ /dev/null @@ -1,379 +0,0 @@ -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links -{ - public sealed class RelativeLinksWithNamespaceTests - : IClassFixture, LinksDbContext>> - { - private readonly ExampleIntegrationTestContext, LinksDbContext> _testContext; - private readonly LinksFakers _fakers = new LinksFakers(); - - public RelativeLinksWithNamespaceTests(ExampleIntegrationTestContext, LinksDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - testContext.UseController(); - - testContext.ConfigureServicesAfterStartup(services => - { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); - - var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); - options.IncludeTotalResourceCount = true; - } - - [Fact] - public async Task Get_primary_resource_by_ID_returns_relative_links() - { - // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.PhotoAlbums.Add(album); - await dbContext.SaveChangesAsync(); - }); - - string route = "/api/photoAlbums/" + album.StringId; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Links.Self.Should().Be(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.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Links.Self.Should().Be(route); - responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be(route + "/relationships/photos"); - responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be(route + "/photos"); - } - - [Fact] - public async Task Get_primary_resources_with_include_returns_relative_links() - { - // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.PhotoAlbums.Add(album); - await dbContext.SaveChangesAsync(); - }); - - const string route = "/api/photoAlbums?include=photos"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Links.Self.Should().Be(route); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be(route); - responseDocument.Links.Last.Should().Be(route); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); - - string albumLink = $"/api/photoAlbums/{album.StringId}"; - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Links.Self.Should().Be(albumLink); - responseDocument.ManyData[0].Relationships["photos"].Links.Self.Should().Be(albumLink + "/relationships/photos"); - responseDocument.ManyData[0].Relationships["photos"].Links.Related.Should().Be(albumLink + "/photos"); - - string photoLink = $"/api/photos/{album.Photos.ElementAt(0).StringId}"; - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(photoLink); - responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be(photoLink + "/relationships/album"); - responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be(photoLink + "/album"); - } - - [Fact] - public async Task Get_secondary_resource_returns_relative_links() - { - // Arrange - Photo photo = _fakers.Photo.Generate(); - photo.Album = _fakers.PhotoAlbum.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Photos.Add(photo); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/api/photos/{photo.StringId}/album"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Links.Self.Should().Be(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(); - - string albumLink = $"/api/photoAlbums/{photo.Album.StringId}"; - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Links.Self.Should().Be(albumLink); - responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be(albumLink + "/relationships/photos"); - responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be(albumLink + "/photos"); - } - - [Fact] - public async Task Get_secondary_resources_returns_relative_links() - { - // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.PhotoAlbums.Add(album); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/api/photoAlbums/{album.StringId}/photos"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Links.Self.Should().Be(route); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be(route); - responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); - - string photoLink = $"/api/photos/{album.Photos.ElementAt(0).StringId}"; - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Links.Self.Should().Be(photoLink); - responseDocument.ManyData[0].Relationships["album"].Links.Self.Should().Be(photoLink + "/relationships/album"); - responseDocument.ManyData[0].Relationships["album"].Links.Related.Should().Be(photoLink + "/album"); - } - - [Fact] - public async Task Get_HasOne_relationship_returns_relative_links() - { - // Arrange - Photo photo = _fakers.Photo.Generate(); - photo.Album = _fakers.PhotoAlbum.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Photos.Add(photo); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/api/photos/{photo.StringId}/relationships/album"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Links.Self.Should().Be(route); - responseDocument.Links.Related.Should().Be($"/api/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.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Links.Should().BeNull(); - responseDocument.SingleData.Relationships.Should().BeNull(); - } - - [Fact] - public async Task Get_HasMany_relationship_returns_relative_links() - { - // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.PhotoAlbums.Add(album); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/api/photoAlbums/{album.StringId}/relationships/photos"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Links.Self.Should().Be(route); - responseDocument.Links.Related.Should().Be($"/api/photoAlbums/{album.StringId}/photos"); - responseDocument.Links.First.Should().Be(route); - responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Links.Should().BeNull(); - responseDocument.ManyData[0].Relationships.Should().BeNull(); - } - - [Fact] - public async Task Create_resource_with_side_effects_and_include_returns_relative_links() - { - // Arrange - Photo existingPhoto = _fakers.Photo.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Photos.Add(existingPhoto); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "photoAlbums", - relationships = new - { - photos = new - { - data = new[] - { - new - { - type = "photos", - id = existingPhoto.StringId - } - } - } - } - } - }; - - const string route = "/api/photoAlbums?include=photos"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.Links.Self.Should().Be(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(); - - string albumLink = $"/api/photoAlbums/{responseDocument.SingleData.Id}"; - - responseDocument.SingleData.Links.Self.Should().Be(albumLink); - responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be(albumLink + "/relationships/photos"); - responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be(albumLink + "/photos"); - - string photoLink = $"/api/photos/{existingPhoto.StringId}"; - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(photoLink); - responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be(photoLink + "/relationships/album"); - responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be(photoLink + "/album"); - } - - [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(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingPhoto, existingAlbum); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "photos", - id = existingPhoto.StringId, - relationships = new - { - album = new - { - data = new - { - type = "photoAlbums", - id = existingAlbum.StringId - } - } - } - } - }; - - string route = $"/api/photos/{existingPhoto.StringId}?include=album"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Links.Self.Should().Be(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(); - - string photoLink = $"/api/photos/{existingPhoto.StringId}"; - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Links.Self.Should().Be(photoLink); - responseDocument.SingleData.Relationships["album"].Links.Self.Should().Be(photoLink + "/relationships/album"); - responseDocument.SingleData.Relationships["album"].Links.Related.Should().Be(photoLink + "/album"); - - string albumLink = $"/api/photoAlbums/{existingAlbum.StringId}"; - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(albumLink); - responseDocument.Included[0].Relationships["photos"].Links.Self.Should().Be(albumLink + "/relationships/photos"); - responseDocument.Included[0].Relationships["photos"].Links.Related.Should().Be(albumLink + "/photos"); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs deleted file mode 100644 index 4d03d34dd7..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs +++ /dev/null @@ -1,379 +0,0 @@ -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Links -{ - public sealed class RelativeLinksWithoutNamespaceTests - : IClassFixture, LinksDbContext>> - { - private readonly ExampleIntegrationTestContext, LinksDbContext> _testContext; - private readonly LinksFakers _fakers = new LinksFakers(); - - public RelativeLinksWithoutNamespaceTests(ExampleIntegrationTestContext, LinksDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - testContext.UseController(); - - testContext.ConfigureServicesAfterStartup(services => - { - services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); - }); - - var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); - options.IncludeTotalResourceCount = true; - } - - [Fact] - public async Task Get_primary_resource_by_ID_returns_relative_links() - { - // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.PhotoAlbums.Add(album); - await dbContext.SaveChangesAsync(); - }); - - string route = "/photoAlbums/" + album.StringId; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Links.Self.Should().Be(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.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Links.Self.Should().Be(route); - responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be(route + "/relationships/photos"); - responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be(route + "/photos"); - } - - [Fact] - public async Task Get_primary_resources_with_include_returns_relative_links() - { - // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.PhotoAlbums.Add(album); - await dbContext.SaveChangesAsync(); - }); - - const string route = "/photoAlbums?include=photos"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Links.Self.Should().Be(route); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be(route); - responseDocument.Links.Last.Should().Be(route); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); - - string albumLink = $"/photoAlbums/{album.StringId}"; - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Links.Self.Should().Be(albumLink); - responseDocument.ManyData[0].Relationships["photos"].Links.Self.Should().Be(albumLink + "/relationships/photos"); - responseDocument.ManyData[0].Relationships["photos"].Links.Related.Should().Be(albumLink + "/photos"); - - string photoLink = $"/photos/{album.Photos.ElementAt(0).StringId}"; - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(photoLink); - responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be(photoLink + "/relationships/album"); - responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be(photoLink + "/album"); - } - - [Fact] - public async Task Get_secondary_resource_returns_relative_links() - { - // Arrange - Photo photo = _fakers.Photo.Generate(); - photo.Album = _fakers.PhotoAlbum.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Photos.Add(photo); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/photos/{photo.StringId}/album"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Links.Self.Should().Be(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(); - - string albumLink = $"/photoAlbums/{photo.Album.StringId}"; - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Links.Self.Should().Be(albumLink); - responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be(albumLink + "/relationships/photos"); - responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be(albumLink + "/photos"); - } - - [Fact] - public async Task Get_secondary_resources_returns_relative_links() - { - // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.PhotoAlbums.Add(album); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/photoAlbums/{album.StringId}/photos"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Links.Self.Should().Be(route); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be(route); - responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); - - string photoLink = $"/photos/{album.Photos.ElementAt(0).StringId}"; - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Links.Self.Should().Be(photoLink); - responseDocument.ManyData[0].Relationships["album"].Links.Self.Should().Be(photoLink + "/relationships/album"); - responseDocument.ManyData[0].Relationships["album"].Links.Related.Should().Be(photoLink + "/album"); - } - - [Fact] - public async Task Get_HasOne_relationship_returns_relative_links() - { - // Arrange - Photo photo = _fakers.Photo.Generate(); - photo.Album = _fakers.PhotoAlbum.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Photos.Add(photo); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/photos/{photo.StringId}/relationships/album"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Links.Self.Should().Be(route); - responseDocument.Links.Related.Should().Be($"/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.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Links.Should().BeNull(); - responseDocument.SingleData.Relationships.Should().BeNull(); - } - - [Fact] - public async Task Get_HasMany_relationship_returns_relative_links() - { - // Arrange - PhotoAlbum album = _fakers.PhotoAlbum.Generate(); - album.Photos = _fakers.Photo.Generate(1).ToHashSet(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.PhotoAlbums.Add(album); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/photoAlbums/{album.StringId}/relationships/photos"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Links.Self.Should().Be(route); - responseDocument.Links.Related.Should().Be($"/photoAlbums/{album.StringId}/photos"); - responseDocument.Links.First.Should().Be(route); - responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Links.Should().BeNull(); - responseDocument.ManyData[0].Relationships.Should().BeNull(); - } - - [Fact] - public async Task Create_resource_with_side_effects_and_include_returns_relative_links() - { - // Arrange - Photo existingPhoto = _fakers.Photo.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Photos.Add(existingPhoto); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "photoAlbums", - relationships = new - { - photos = new - { - data = new[] - { - new - { - type = "photos", - id = existingPhoto.StringId - } - } - } - } - } - }; - - const string route = "/photoAlbums?include=photos"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.Links.Self.Should().Be(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(); - - string albumLink = $"/photoAlbums/{responseDocument.SingleData.Id}"; - - responseDocument.SingleData.Links.Self.Should().Be(albumLink); - responseDocument.SingleData.Relationships["photos"].Links.Self.Should().Be(albumLink + "/relationships/photos"); - responseDocument.SingleData.Relationships["photos"].Links.Related.Should().Be(albumLink + "/photos"); - - string photoLink = $"/photos/{existingPhoto.StringId}"; - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(photoLink); - responseDocument.Included[0].Relationships["album"].Links.Self.Should().Be(photoLink + "/relationships/album"); - responseDocument.Included[0].Relationships["album"].Links.Related.Should().Be(photoLink + "/album"); - } - - [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(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingPhoto, existingAlbum); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "photos", - id = existingPhoto.StringId, - relationships = new - { - album = new - { - data = new - { - type = "photoAlbums", - id = existingAlbum.StringId - } - } - } - } - }; - - string route = $"/photos/{existingPhoto.StringId}?include=album"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Links.Self.Should().Be(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(); - - string photoLink = $"/photos/{existingPhoto.StringId}"; - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Links.Self.Should().Be(photoLink); - responseDocument.SingleData.Relationships["album"].Links.Self.Should().Be(photoLink + "/relationships/album"); - responseDocument.SingleData.Relationships["album"].Links.Related.Should().Be(photoLink + "/album"); - - string albumLink = $"/photoAlbums/{existingAlbum.StringId}"; - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(albumLink); - responseDocument.Included[0].Relationships["photos"].Links.Self.Should().Be(albumLink + "/relationships/photos"); - responseDocument.Included[0].Relationships["photos"].Links.Related.Should().Be(albumLink + "/photos"); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditDbContext.cs deleted file mode 100644 index 416b381728..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditDbContext.cs +++ /dev/null @@ -1,16 +0,0 @@ -using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Logging -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class AuditDbContext : DbContext - { - public DbSet AuditEntries { get; set; } - - public AuditDbContext(DbContextOptions options) - : base(options) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditEntriesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditEntriesController.cs deleted file mode 100644 index b825a3fb0b..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditEntriesController.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Logging -{ - public sealed class AuditEntriesController : JsonApiController - { - public AuditEntriesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditEntry.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditEntry.cs deleted file mode 100644 index d7001f400f..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditEntry.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Logging -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class AuditEntry : Identifiable - { - [Attr] - public string UserName { get; set; } - - [Attr] - public DateTimeOffset CreatedAt { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditFakers.cs deleted file mode 100644 index 702411dddd..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/AuditFakers.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using Bogus; -using TestBuildingBlocks; - -// @formatter:wrap_chained_method_calls chop_always -// @formatter:keep_existing_linebreaks true - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Logging -{ - internal sealed class AuditFakers : FakerContainer - { - private readonly Lazy> _lazyAuditEntryFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(auditEntry => auditEntry.UserName, faker => faker.Internet.UserName()) - .RuleFor(auditEntry => auditEntry.CreatedAt, faker => faker.Date.PastOffset())); - - public Faker AuditEntry => _lazyAuditEntryFaker.Value; - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/LoggingTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/LoggingTests.cs deleted file mode 100644 index cc9ab85ce4..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Logging/LoggingTests.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Logging -{ - public sealed class LoggingTests : IClassFixture, AuditDbContext>> - { - private readonly ExampleIntegrationTestContext, AuditDbContext> _testContext; - private readonly AuditFakers _fakers = new AuditFakers(); - - public LoggingTests(ExampleIntegrationTestContext, AuditDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - - FakeLoggerFactory loggerFactory = null; - - testContext.ConfigureLogging(options => - { - loggerFactory = new FakeLoggerFactory(); - - options.ClearProviders(); - options.AddProvider(loggerFactory); - options.SetMinimumLevel(LogLevel.Trace); - options.AddFilter((_, __) => true); - }); - - testContext.ConfigureServicesBeforeStartup(services => - { - if (loggerFactory != null) - { - services.AddSingleton(_ => loggerFactory); - } - }); - } - - [Fact] - public async Task Logs_request_body_at_Trace_level() - { - // Arrange - var loggerFactory = _testContext.Factory.Services.GetRequiredService(); - loggerFactory.Logger.Clear(); - - AuditEntry newEntry = _fakers.AuditEntry.Generate(); - - var requestBody = new - { - data = new - { - type = "auditEntries", - attributes = new - { - userName = newEntry.UserName, - createdAt = newEntry.CreatedAt - } - } - }; - - // Arrange - const string route = "/auditEntries"; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - loggerFactory.Logger.Messages.Should().NotBeEmpty(); - - loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Trace && - message.Text.StartsWith("Received 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(); - - // Arrange - const string route = "/auditEntries"; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - loggerFactory.Logger.Messages.Should().NotBeEmpty(); - - 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)); - } - - [Fact] - public async Task Logs_invalid_request_body_error_at_Information_level() - { - // Arrange - var loggerFactory = _testContext.Factory.Services.GetRequiredService(); - loggerFactory.Logger.Clear(); - - // Arrange - const string requestBody = "{ \"data\" {"; - - const string route = "/auditEntries"; - - // Act - (HttpResponseMessage httpResponse, _) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - loggerFactory.Logger.Messages.Should().NotBeEmpty(); - - loggerFactory.Logger.Messages.Should().ContainSingle(message => message.LogLevel == LogLevel.Information && - message.Text.Contains("Failed to deserialize request body.")); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ProductFamiliesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ProductFamiliesController.cs deleted file mode 100644 index a276769c63..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ProductFamiliesController.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta -{ - public sealed class ProductFamiliesController : JsonApiController - { - public ProductFamiliesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ProductFamily.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ProductFamily.cs deleted file mode 100644 index ea40d2c09f..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ProductFamily.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class ProductFamily : Identifiable - { - [Attr] - public string Name { get; set; } - - [HasMany] - public IList Tickets { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs deleted file mode 100644 index f968dc1840..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResourceMetaTests.cs +++ /dev/null @@ -1,110 +0,0 @@ -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta -{ - public sealed class ResourceMetaTests : IClassFixture, SupportDbContext>> - { - private readonly ExampleIntegrationTestContext, SupportDbContext> _testContext; - private readonly SupportFakers _fakers = new SupportFakers(); - - public ResourceMetaTests(ExampleIntegrationTestContext, SupportDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - testContext.UseController(); - - testContext.ConfigureServicesAfterStartup(services => - { - services.AddResourceDefinition(); - services.AddSingleton(); - }); - - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - hitCounter.Reset(); - } - - [Fact] - public async Task Returns_resource_meta_from_ResourceDefinition() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - List tickets = _fakers.SupportTicket.Generate(3); - tickets[0].Description = "Critical: " + tickets[0].Description; - tickets[2].Description = "Critical: " + tickets[2].Description; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.SupportTickets.AddRange(tickets); - await dbContext.SaveChangesAsync(); - }); - - const string route = "/supportTickets"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(3); - responseDocument.ManyData[0].Meta.Should().ContainKey("hasHighPriority"); - responseDocument.ManyData[1].Meta.Should().BeNull(); - responseDocument.ManyData[2].Meta.Should().ContainKey("hasHighPriority"); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(SupportTicket), ResourceDefinitionHitCounter.ExtensibilityPoint.GetMeta), - (typeof(SupportTicket), ResourceDefinitionHitCounter.ExtensibilityPoint.GetMeta), - (typeof(SupportTicket), ResourceDefinitionHitCounter.ExtensibilityPoint.GetMeta) - }, options => options.WithStrictOrdering()); - } - - [Fact] - public async Task Returns_resource_meta_from_ResourceDefinition_in_included_resources() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - ProductFamily family = _fakers.ProductFamily.Generate(); - family.Tickets = _fakers.SupportTicket.Generate(1); - family.Tickets[0].Description = "Critical: " + family.Tickets[0].Description; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.ProductFamilies.Add(family); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/productFamilies/{family.StringId}?include=tickets"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Meta.Should().ContainKey("hasHighPriority"); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(SupportTicket), ResourceDefinitionHitCounter.ExtensibilityPoint.GetMeta) - }, options => options.WithStrictOrdering()); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResponseMetaTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResponseMetaTests.cs deleted file mode 100644 index e10b383a33..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/ResponseMetaTests.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta -{ - public sealed class ResponseMetaTests : IClassFixture, SupportDbContext>> - { - private readonly ExampleIntegrationTestContext, SupportDbContext> _testContext; - - public ResponseMetaTests(ExampleIntegrationTestContext, SupportDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - testContext.UseController(); - - testContext.ConfigureServicesAfterStartup(services => - { - services.AddSingleton(); - }); - - var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); - options.IncludeTotalResourceCount = false; - } - - [Fact] - public async Task Returns_top_level_meta() - { - // Arrange - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - }); - - const string route = "/supportTickets"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Should().BeJson(@"{ - ""meta"": { - ""license"": ""MIT"", - ""projectUrl"": ""https://github.com/json-api-dotnet/JsonApiDotNetCore/"", - ""versions"": [ - ""v4.0.0"", - ""v3.1.0"", - ""v2.5.2"", - ""v1.3.1"" - ] - }, - ""links"": { - ""self"": ""http://localhost/supportTickets"", - ""first"": ""http://localhost/supportTickets"" - }, - ""data"": [] -}"); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportDbContext.cs deleted file mode 100644 index 0e0376264c..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportDbContext.cs +++ /dev/null @@ -1,17 +0,0 @@ -using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class SupportDbContext : DbContext - { - public DbSet ProductFamilies { get; set; } - public DbSet SupportTickets { get; set; } - - public SupportDbContext(DbContextOptions options) - : base(options) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportFakers.cs deleted file mode 100644 index 797696c8f6..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportFakers.cs +++ /dev/null @@ -1,25 +0,0 @@ -using System; -using Bogus; -using TestBuildingBlocks; - -// @formatter:wrap_chained_method_calls chop_always -// @formatter:keep_existing_linebreaks true - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta -{ - internal sealed class SupportFakers : FakerContainer - { - private readonly Lazy> _lazyProductFamilyFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(productFamily => productFamily.Name, faker => faker.Commerce.ProductName())); - - private readonly Lazy> _lazySupportTicketFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(supportTicket => supportTicket.Description, faker => faker.Lorem.Paragraph())); - - public Faker ProductFamily => _lazyProductFamilyFaker.Value; - public Faker SupportTicket => _lazySupportTicketFaker.Value; - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportResponseMeta.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportResponseMeta.cs deleted file mode 100644 index 1b62252195..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportResponseMeta.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Collections.Generic; -using JsonApiDotNetCore.Serialization; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta -{ - public sealed class SupportResponseMeta : IResponseMeta - { - public IReadOnlyDictionary GetMeta() - { - return new Dictionary - { - ["license"] = "MIT", - ["projectUrl"] = "https://github.com/json-api-dotnet/JsonApiDotNetCore/", - ["versions"] = new[] - { - "v4.0.0", - "v3.1.0", - "v2.5.2", - "v1.3.1" - } - }; - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportTicket.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportTicket.cs deleted file mode 100644 index b93fcb6b8c..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportTicket.cs +++ /dev/null @@ -1,13 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class SupportTicket : Identifiable - { - [Attr] - public string Description { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportTicketDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportTicketDefinition.cs deleted file mode 100644 index cb68f863ec..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportTicketDefinition.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System; -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta -{ - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class SupportTicketDefinition : JsonApiResourceDefinition - { - private readonly ResourceDefinitionHitCounter _hitCounter; - - public SupportTicketDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph) - { - _hitCounter = hitCounter; - } - - public override IDictionary GetMeta(SupportTicket resource) - { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.GetMeta); - - if (resource.Description != null && resource.Description.StartsWith("Critical:", StringComparison.Ordinal)) - { - return new Dictionary - { - ["hasHighPriority"] = true - }; - } - - return base.GetMeta(resource); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportTicketsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportTicketsController.cs deleted file mode 100644 index a08e11371c..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/SupportTicketsController.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta -{ - public sealed class SupportTicketsController : JsonApiController - { - public SupportTicketsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/TopLevelCountTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/TopLevelCountTests.cs deleted file mode 100644 index 176a526774..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Meta/TopLevelCountTests.cs +++ /dev/null @@ -1,149 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Meta -{ - public sealed class TopLevelCountTests : IClassFixture, SupportDbContext>> - { - private readonly ExampleIntegrationTestContext, SupportDbContext> _testContext; - private readonly SupportFakers _fakers = new SupportFakers(); - - public TopLevelCountTests(ExampleIntegrationTestContext, SupportDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - testContext.UseController(); - - testContext.ConfigureServicesAfterStartup(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() - { - // Arrange - SupportTicket ticket = _fakers.SupportTicket.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.SupportTickets.Add(ticket); - await dbContext.SaveChangesAsync(); - }); - - const string route = "/supportTickets"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Meta.Should().NotBeNull(); - responseDocument.Meta["totalResources"].Should().Be(1); - } - - [Fact] - public async Task Renders_resource_count_for_empty_collection() - { - // Arrange - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - }); - - const string route = "/supportTickets"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Meta.Should().NotBeNull(); - responseDocument.Meta["totalResources"].Should().Be(0); - } - - [Fact] - public async Task Hides_resource_count_in_create_resource_response() - { - // Arrange - string newDescription = _fakers.SupportTicket.Generate().Description; - - var requestBody = new - { - data = new - { - type = "supportTickets", - attributes = new - { - description = newDescription - } - } - }; - - const string route = "/supportTickets"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.Meta.Should().BeNull(); - } - - [Fact] - public async Task Hides_resource_count_in_update_resource_response() - { - // Arrange - SupportTicket existingTicket = _fakers.SupportTicket.Generate(); - - string newDescription = _fakers.SupportTicket.Generate().Description; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.SupportTickets.Add(existingTicket); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "supportTickets", - id = existingTicket.StringId, - attributes = new - { - description = newDescription - } - } - }; - - string route = "/supportTickets/" + existingTicket.StringId; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Meta.Should().BeNull(); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainFakers.cs deleted file mode 100644 index 9e69ec8689..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainFakers.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using Bogus; -using TestBuildingBlocks; - -// @formatter:wrap_chained_method_calls chop_always -// @formatter:keep_existing_linebreaks true - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices -{ - internal sealed class DomainFakers : FakerContainer - { - private readonly Lazy> _lazyDomainUserFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(domainUser => domainUser.LoginName, faker => faker.Person.UserName) - .RuleFor(domainUser => domainUser.DisplayName, faker => faker.Person.FullName)); - - private readonly Lazy> _lazyDomainGroupFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(domainGroup => domainGroup.Name, faker => faker.Commerce.Department())); - - public Faker DomainUser => _lazyDomainUserFaker.Value; - public Faker DomainGroup => _lazyDomainGroupFaker.Value; - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainGroup.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainGroup.cs deleted file mode 100644 index 306aa2c907..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainGroup.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class DomainGroup : Identifiable - { - [Attr] - public string Name { get; set; } - - [HasMany] - public ISet Users { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainGroupsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainGroupsController.cs deleted file mode 100644 index efd737881b..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainGroupsController.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices -{ - public sealed class DomainGroupsController : JsonApiController - { - public DomainGroupsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainUser.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainUser.cs deleted file mode 100644 index 538b2a31bd..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainUser.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; -using System.ComponentModel.DataAnnotations; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class DomainUser : Identifiable - { - [Attr] - [Required] - public string LoginName { get; set; } - - [Attr] - public string DisplayName { get; set; } - - [HasOne] - public DomainGroup Group { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainUsersController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainUsersController.cs deleted file mode 100644 index 48b26e2acd..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/DomainUsersController.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices -{ - public sealed class DomainUsersController : JsonApiController - { - public DomainUsersController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetDbContext.cs deleted file mode 100644 index 259bafb87e..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetDbContext.cs +++ /dev/null @@ -1,17 +0,0 @@ -using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.FireAndForgetDelivery -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class FireForgetDbContext : DbContext - { - public DbSet Users { get; set; } - public DbSet Groups { get; set; } - - public FireForgetDbContext(DbContextOptions options) - : base(options) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetGroupDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetGroupDefinition.cs deleted file mode 100644 index 803afbf723..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetGroupDefinition.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.FireAndForgetDelivery -{ - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class FireForgetGroupDefinition : MessagingGroupDefinition - { - private readonly MessageBroker _messageBroker; - private readonly ResourceDefinitionHitCounter _hitCounter; - private DomainGroup _groupToDelete; - - public FireForgetGroupDefinition(IResourceGraph resourceGraph, FireForgetDbContext dbContext, MessageBroker messageBroker, - ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph, dbContext.Users, dbContext.Groups, hitCounter) - { - _messageBroker = messageBroker; - _hitCounter = hitCounter; - } - - public override async Task OnWritingAsync(DomainGroup group, OperationKind operationKind, CancellationToken cancellationToken) - { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync); - - if (operationKind == OperationKind.DeleteResource) - { - _groupToDelete = await base.GetGroupToDeleteAsync(group.Id, cancellationToken); - } - } - - public override Task OnWriteSucceededAsync(DomainGroup group, OperationKind operationKind, CancellationToken cancellationToken) - { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync); - - return FinishWriteAsync(group, operationKind, cancellationToken); - } - - protected override Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken) - { - return _messageBroker.PostMessageAsync(message, cancellationToken); - } - - protected override Task GetGroupToDeleteAsync(Guid groupId, CancellationToken cancellationToken) - { - return Task.FromResult(_groupToDelete); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs deleted file mode 100644 index 1c22f1834a..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.Group.cs +++ /dev/null @@ -1,589 +0,0 @@ -using System; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.FireAndForgetDelivery -{ - public sealed partial class FireForgetTests - { - [Fact] - public async Task Create_group_sends_messages() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); - - string newGroupName = _fakers.DomainGroup.Generate().Name; - - var requestBody = new - { - data = new - { - type = "domainGroups", - attributes = new - { - name = newGroupName - } - } - }; - - const string route = "/domainGroups"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["name"].Should().Be(newGroupName); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - messageBroker.SentMessages.Should().HaveCount(1); - - Guid newGroupId = Guid.Parse(responseDocument.SingleData.Id); - - var content = messageBroker.SentMessages[0].GetContentAs(); - content.GroupId.Should().Be(newGroupId); - content.GroupName.Should().Be(newGroupName); - } - - [Fact] - public async Task Create_group_with_users_sends_messages() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); - - DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); - - DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); - existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); - - string newGroupName = _fakers.DomainGroup.Generate().Name; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithOtherGroup); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "domainGroups", - attributes = new - { - name = newGroupName - }, - relationships = new - { - users = new - { - data = new[] - { - new - { - type = "domainUsers", - id = existingUserWithoutGroup.StringId - }, - new - { - type = "domainUsers", - id = existingUserWithOtherGroup.StringId - } - } - } - } - } - }; - - const string route = "/domainGroups"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["name"].Should().Be(newGroupName); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToManyRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - messageBroker.SentMessages.Should().HaveCount(3); - - Guid newGroupId = Guid.Parse(responseDocument.SingleData.Id); - - var content1 = messageBroker.SentMessages[0].GetContentAs(); - content1.GroupId.Should().Be(newGroupId); - content1.GroupName.Should().Be(newGroupName); - - var content2 = messageBroker.SentMessages[1].GetContentAs(); - content2.UserId.Should().Be(existingUserWithoutGroup.Id); - content2.GroupId.Should().Be(newGroupId); - - var content3 = messageBroker.SentMessages[2].GetContentAs(); - content3.UserId.Should().Be(existingUserWithOtherGroup.Id); - content3.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); - content3.AfterGroupId.Should().Be(newGroupId); - } - - [Fact] - public async Task Update_group_sends_messages() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); - - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - - string newGroupName = _fakers.DomainGroup.Generate().Name; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Groups.Add(existingGroup); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "domainGroups", - id = existingGroup.StringId, - attributes = new - { - name = newGroupName - } - } - }; - - string route = "/domainGroups/" + existingGroup.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - messageBroker.SentMessages.Should().HaveCount(1); - - var content = messageBroker.SentMessages[0].GetContentAs(); - content.GroupId.Should().Be(existingGroup.StringId); - content.BeforeGroupName.Should().Be(existingGroup.Name); - content.AfterGroupName.Should().Be(newGroupName); - } - - [Fact] - public async Task Update_group_with_users_sends_messages() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); - - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - - DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); - - DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); - existingUserWithSameGroup1.Group = existingGroup; - - DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); - existingUserWithSameGroup2.Group = existingGroup; - - DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); - existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithSameGroup1, existingUserWithSameGroup2, existingUserWithOtherGroup); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "domainGroups", - id = existingGroup.StringId, - relationships = new - { - users = new - { - data = new[] - { - new - { - type = "domainUsers", - id = existingUserWithoutGroup.StringId - }, - new - { - type = "domainUsers", - id = existingUserWithSameGroup1.StringId - }, - new - { - type = "domainUsers", - id = existingUserWithOtherGroup.StringId - } - } - } - } - } - }; - - string route = "/domainGroups/" + existingGroup.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToManyRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - messageBroker.SentMessages.Should().HaveCount(3); - - var content1 = messageBroker.SentMessages[0].GetContentAs(); - content1.UserId.Should().Be(existingUserWithoutGroup.Id); - content1.GroupId.Should().Be(existingGroup.Id); - - var content2 = messageBroker.SentMessages[1].GetContentAs(); - content2.UserId.Should().Be(existingUserWithOtherGroup.Id); - content2.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); - content2.AfterGroupId.Should().Be(existingGroup.Id); - - var content3 = messageBroker.SentMessages[2].GetContentAs(); - content3.UserId.Should().Be(existingUserWithSameGroup2.Id); - content3.GroupId.Should().Be(existingGroup.Id); - } - - [Fact] - public async Task Delete_group_sends_messages() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); - - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Groups.Add(existingGroup); - await dbContext.SaveChangesAsync(); - }); - - string route = "/domainGroups/" + existingGroup.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - messageBroker.SentMessages.Should().HaveCount(1); - - var content = messageBroker.SentMessages[0].GetContentAs(); - content.GroupId.Should().Be(existingGroup.StringId); - } - - [Fact] - public async Task Delete_group_with_users_sends_messages() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); - - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - existingGroup.Users = _fakers.DomainUser.Generate(1).ToHashSet(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Groups.Add(existingGroup); - await dbContext.SaveChangesAsync(); - }); - - string route = "/domainGroups/" + existingGroup.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - messageBroker.SentMessages.Should().HaveCount(2); - - var content1 = messageBroker.SentMessages[0].GetContentAs(); - content1.UserId.Should().Be(existingGroup.Users.ElementAt(0).Id); - content1.GroupId.Should().Be(existingGroup.StringId); - - var content2 = messageBroker.SentMessages[1].GetContentAs(); - content2.GroupId.Should().Be(existingGroup.StringId); - } - - [Fact] - public async Task Replace_users_in_group_sends_messages() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); - - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - - DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); - - DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); - existingUserWithSameGroup1.Group = existingGroup; - - DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); - existingUserWithSameGroup2.Group = existingGroup; - - DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); - existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithSameGroup1, existingUserWithSameGroup2, existingUserWithOtherGroup); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "domainUsers", - id = existingUserWithoutGroup.StringId - }, - new - { - type = "domainUsers", - id = existingUserWithSameGroup1.StringId - }, - new - { - type = "domainUsers", - id = existingUserWithOtherGroup.StringId - } - } - }; - - string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToManyRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - messageBroker.SentMessages.Should().HaveCount(3); - - var content1 = messageBroker.SentMessages[0].GetContentAs(); - content1.UserId.Should().Be(existingUserWithoutGroup.Id); - content1.GroupId.Should().Be(existingGroup.Id); - - var content2 = messageBroker.SentMessages[1].GetContentAs(); - content2.UserId.Should().Be(existingUserWithOtherGroup.Id); - content2.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); - content2.AfterGroupId.Should().Be(existingGroup.Id); - - var content3 = messageBroker.SentMessages[2].GetContentAs(); - content3.UserId.Should().Be(existingUserWithSameGroup2.Id); - content3.GroupId.Should().Be(existingGroup.Id); - } - - [Fact] - public async Task Add_users_to_group_sends_messages() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); - - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - - DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); - - DomainUser existingUserWithSameGroup = _fakers.DomainUser.Generate(); - existingUserWithSameGroup.Group = existingGroup; - - DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); - existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithSameGroup, existingUserWithOtherGroup); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "domainUsers", - id = existingUserWithoutGroup.StringId - }, - new - { - type = "domainUsers", - id = existingUserWithOtherGroup.StringId - } - } - }; - - string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnAddToRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - messageBroker.SentMessages.Should().HaveCount(2); - - var content1 = messageBroker.SentMessages[0].GetContentAs(); - content1.UserId.Should().Be(existingUserWithoutGroup.Id); - content1.GroupId.Should().Be(existingGroup.Id); - - var content2 = messageBroker.SentMessages[1].GetContentAs(); - content2.UserId.Should().Be(existingUserWithOtherGroup.Id); - content2.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); - content2.AfterGroupId.Should().Be(existingGroup.Id); - } - - [Fact] - public async Task Remove_users_from_group_sends_messages() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); - - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - - DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); - existingUserWithSameGroup1.Group = existingGroup; - - DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); - existingUserWithSameGroup2.Group = existingGroup; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Users.AddRange(existingUserWithSameGroup1, existingUserWithSameGroup2); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "domainUsers", - id = existingUserWithSameGroup2.StringId - } - } - }; - - string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnRemoveFromRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - messageBroker.SentMessages.Should().HaveCount(1); - - var content = messageBroker.SentMessages[0].GetContentAs(); - content.UserId.Should().Be(existingUserWithSameGroup2.Id); - content.GroupId.Should().Be(existingGroup.Id); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.User.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.User.cs deleted file mode 100644 index bbbfc12da8..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.User.cs +++ /dev/null @@ -1,647 +0,0 @@ -using System; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.FireAndForgetDelivery -{ - public sealed partial class FireForgetTests - { - [Fact] - public async Task Create_user_sends_messages() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); - - string newLoginName = _fakers.DomainUser.Generate().LoginName; - string newDisplayName = _fakers.DomainUser.Generate().DisplayName; - - var requestBody = new - { - data = new - { - type = "domainUsers", - attributes = new - { - loginName = newLoginName, - displayName = newDisplayName - } - } - }; - - const string route = "/domainUsers"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["loginName"].Should().Be(newLoginName); - responseDocument.SingleData.Attributes["displayName"].Should().Be(newDisplayName); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - messageBroker.SentMessages.Should().HaveCount(1); - - Guid newUserId = Guid.Parse(responseDocument.SingleData.Id); - - var content = messageBroker.SentMessages[0].GetContentAs(); - content.UserId.Should().Be(newUserId); - content.UserLoginName.Should().Be(newLoginName); - content.UserDisplayName.Should().Be(newDisplayName); - } - - [Fact] - public async Task Create_user_in_group_sends_messages() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); - - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - - string newLoginName = _fakers.DomainUser.Generate().LoginName; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Groups.Add(existingGroup); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "domainUsers", - attributes = new - { - loginName = newLoginName - }, - relationships = new - { - group = new - { - data = new - { - type = "domainGroups", - id = existingGroup.StringId - } - } - } - } - }; - - const string route = "/domainUsers"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["loginName"].Should().Be(newLoginName); - responseDocument.SingleData.Attributes["displayName"].Should().BeNull(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - messageBroker.SentMessages.Should().HaveCount(2); - - Guid newUserId = Guid.Parse(responseDocument.SingleData.Id); - - var content1 = messageBroker.SentMessages[0].GetContentAs(); - content1.UserId.Should().Be(newUserId); - content1.UserLoginName.Should().Be(newLoginName); - content1.UserDisplayName.Should().BeNull(); - - var content2 = messageBroker.SentMessages[1].GetContentAs(); - content2.UserId.Should().Be(newUserId); - content2.GroupId.Should().Be(existingGroup.Id); - } - - [Fact] - public async Task Update_user_sends_messages() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); - - DomainUser existingUser = _fakers.DomainUser.Generate(); - - string newLoginName = _fakers.DomainUser.Generate().LoginName; - string newDisplayName = _fakers.DomainUser.Generate().DisplayName; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Users.Add(existingUser); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "domainUsers", - id = existingUser.StringId, - attributes = new - { - loginName = newLoginName, - displayName = newDisplayName - } - } - }; - - string route = "/domainUsers/" + existingUser.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - messageBroker.SentMessages.Should().HaveCount(2); - - var content1 = messageBroker.SentMessages[0].GetContentAs(); - content1.UserId.Should().Be(existingUser.Id); - content1.BeforeUserLoginName.Should().Be(existingUser.LoginName); - content1.AfterUserLoginName.Should().Be(newLoginName); - - var content2 = messageBroker.SentMessages[1].GetContentAs(); - content2.UserId.Should().Be(existingUser.Id); - content2.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); - content2.AfterUserDisplayName.Should().Be(newDisplayName); - } - - [Fact] - public async Task Update_user_clear_group_sends_messages() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); - - DomainUser existingUser = _fakers.DomainUser.Generate(); - existingUser.Group = _fakers.DomainGroup.Generate(); - - string newDisplayName = _fakers.DomainUser.Generate().DisplayName; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Users.Add(existingUser); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "domainUsers", - id = existingUser.StringId, - attributes = new - { - displayName = newDisplayName - }, - relationships = new - { - group = new - { - data = (object)null - } - } - } - }; - - string route = "/domainUsers/" + existingUser.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - messageBroker.SentMessages.Should().HaveCount(2); - - var content1 = messageBroker.SentMessages[0].GetContentAs(); - content1.UserId.Should().Be(existingUser.Id); - content1.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); - content1.AfterUserDisplayName.Should().Be(newDisplayName); - - var content2 = messageBroker.SentMessages[1].GetContentAs(); - content2.UserId.Should().Be(existingUser.Id); - content2.GroupId.Should().Be(existingUser.Group.Id); - } - - [Fact] - public async Task Update_user_add_to_group_sends_messages() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); - - DomainUser existingUser = _fakers.DomainUser.Generate(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - - string newDisplayName = _fakers.DomainUser.Generate().DisplayName; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingUser, existingGroup); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "domainUsers", - id = existingUser.StringId, - attributes = new - { - displayName = newDisplayName - }, - relationships = new - { - group = new - { - data = new - { - type = "domainGroups", - id = existingGroup.StringId - } - } - } - } - }; - - string route = "/domainUsers/" + existingUser.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - messageBroker.SentMessages.Should().HaveCount(2); - - var content1 = messageBroker.SentMessages[0].GetContentAs(); - content1.UserId.Should().Be(existingUser.Id); - content1.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); - content1.AfterUserDisplayName.Should().Be(newDisplayName); - - var content2 = messageBroker.SentMessages[1].GetContentAs(); - content2.UserId.Should().Be(existingUser.Id); - content2.GroupId.Should().Be(existingGroup.Id); - } - - [Fact] - public async Task Update_user_move_to_group_sends_messages() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); - - DomainUser existingUser = _fakers.DomainUser.Generate(); - existingUser.Group = _fakers.DomainGroup.Generate(); - - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - - string newDisplayName = _fakers.DomainUser.Generate().DisplayName; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingUser, existingGroup); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "domainUsers", - id = existingUser.StringId, - attributes = new - { - displayName = newDisplayName - }, - relationships = new - { - group = new - { - data = new - { - type = "domainGroups", - id = existingGroup.StringId - } - } - } - } - }; - - string route = "/domainUsers/" + existingUser.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - messageBroker.SentMessages.Should().HaveCount(2); - - var content1 = messageBroker.SentMessages[0].GetContentAs(); - content1.UserId.Should().Be(existingUser.Id); - content1.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); - content1.AfterUserDisplayName.Should().Be(newDisplayName); - - var content2 = messageBroker.SentMessages[1].GetContentAs(); - content2.UserId.Should().Be(existingUser.Id); - content2.BeforeGroupId.Should().Be(existingUser.Group.Id); - content2.AfterGroupId.Should().Be(existingGroup.Id); - } - - [Fact] - public async Task Delete_user_sends_messages() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); - - DomainUser existingUser = _fakers.DomainUser.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Users.Add(existingUser); - await dbContext.SaveChangesAsync(); - }); - - string route = "/domainUsers/" + existingUser.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - messageBroker.SentMessages.Should().HaveCount(1); - - var content = messageBroker.SentMessages[0].GetContentAs(); - content.UserId.Should().Be(existingUser.Id); - } - - [Fact] - public async Task Delete_user_in_group_sends_messages() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); - - DomainUser existingUser = _fakers.DomainUser.Generate(); - existingUser.Group = _fakers.DomainGroup.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Users.Add(existingUser); - await dbContext.SaveChangesAsync(); - }); - - string route = "/domainUsers/" + existingUser.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - messageBroker.SentMessages.Should().HaveCount(2); - - var content1 = messageBroker.SentMessages[0].GetContentAs(); - content1.UserId.Should().Be(existingUser.Id); - content1.GroupId.Should().Be(existingUser.Group.Id); - - var content2 = messageBroker.SentMessages[1].GetContentAs(); - content2.UserId.Should().Be(existingUser.Id); - } - - [Fact] - public async Task Clear_group_from_user_sends_messages() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); - - DomainUser existingUser = _fakers.DomainUser.Generate(); - existingUser.Group = _fakers.DomainGroup.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.Users.Add(existingUser); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = (object)null - }; - - string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - messageBroker.SentMessages.Should().HaveCount(1); - - var content = messageBroker.SentMessages[0].GetContentAs(); - content.UserId.Should().Be(existingUser.Id); - content.GroupId.Should().Be(existingUser.Group.Id); - } - - [Fact] - public async Task Assign_group_to_user_sends_messages() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); - - DomainUser existingUser = _fakers.DomainUser.Generate(); - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingUser, existingGroup); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "domainGroups", - id = existingGroup.StringId - } - }; - - string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - messageBroker.SentMessages.Should().HaveCount(1); - - var content = messageBroker.SentMessages[0].GetContentAs(); - content.UserId.Should().Be(existingUser.Id); - content.GroupId.Should().Be(existingGroup.Id); - } - - [Fact] - public async Task Replace_group_for_user_sends_messages() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); - - DomainUser existingUser = _fakers.DomainUser.Generate(); - existingUser.Group = _fakers.DomainGroup.Generate(); - - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingUser, existingGroup); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "domainGroups", - id = existingGroup.StringId - } - }; - - string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - messageBroker.SentMessages.Should().HaveCount(1); - - var content = messageBroker.SentMessages[0].GetContentAs(); - content.UserId.Should().Be(existingUser.Id); - content.BeforeGroupId.Should().Be(existingUser.Group.Id); - content.AfterGroupId.Should().Be(existingGroup.Id); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.cs deleted file mode 100644 index b36fae4173..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetTests.cs +++ /dev/null @@ -1,123 +0,0 @@ -using System; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.FireAndForgetDelivery -{ - public sealed partial class FireForgetTests : IClassFixture, FireForgetDbContext>> - { - private readonly ExampleIntegrationTestContext, FireForgetDbContext> _testContext; - private readonly DomainFakers _fakers = new DomainFakers(); - - public FireForgetTests(ExampleIntegrationTestContext, FireForgetDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - testContext.UseController(); - - testContext.ConfigureServicesAfterStartup(services => - { - services.AddResourceDefinition(); - services.AddResourceDefinition(); - - services.AddSingleton(); - services.AddSingleton(); - }); - - var messageBroker = _testContext.Factory.Services.GetRequiredService(); - messageBroker.Reset(); - - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - hitCounter.Reset(); - } - - [Fact] - public async Task Does_not_send_message_on_write_error() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - var messageBroker = _testContext.Factory.Services.GetRequiredService(); - - string missingUserId = Guid.NewGuid().ToString(); - - string route = "/domainUsers/" + missingUserId; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteDeleteAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 'domainUsers' with ID '{missingUserId}' does not exist."); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) - }, options => options.WithStrictOrdering()); - - messageBroker.SentMessages.Should().BeEmpty(); - } - - [Fact] - public async Task Does_not_rollback_on_message_delivery_error() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - var messageBroker = _testContext.Factory.Services.GetRequiredService(); - messageBroker.SimulateFailure = true; - - DomainUser existingUser = _fakers.DomainUser.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Users.Add(existingUser); - await dbContext.SaveChangesAsync(); - }); - - string route = "/domainUsers/" + existingUser.StringId; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteDeleteAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.ServiceUnavailable); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable); - error.Title.Should().Be("Message delivery failed."); - error.Detail.Should().BeNull(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync) - }, options => options.WithStrictOrdering()); - - messageBroker.SentMessages.Should().HaveCount(1); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - DomainUser user = await dbContext.Users.FirstWithIdOrDefaultAsync(existingUser.Id); - user.Should().BeNull(); - }); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetUserDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetUserDefinition.cs deleted file mode 100644 index 72df77a2e2..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/FireForgetUserDefinition.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.FireAndForgetDelivery -{ - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class FireForgetUserDefinition : MessagingUserDefinition - { - private readonly MessageBroker _messageBroker; - private readonly ResourceDefinitionHitCounter _hitCounter; - private DomainUser _userToDelete; - - public FireForgetUserDefinition(IResourceGraph resourceGraph, FireForgetDbContext dbContext, MessageBroker messageBroker, - ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph, dbContext.Users, hitCounter) - { - _messageBroker = messageBroker; - _hitCounter = hitCounter; - } - - public override async Task OnWritingAsync(DomainUser user, OperationKind operationKind, CancellationToken cancellationToken) - { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync); - - if (operationKind == OperationKind.DeleteResource) - { - _userToDelete = await base.GetUserToDeleteAsync(user.Id, cancellationToken); - } - } - - public override Task OnWriteSucceededAsync(DomainUser user, OperationKind operationKind, CancellationToken cancellationToken) - { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.OnWriteSucceededAsync); - - return FinishWriteAsync(user, operationKind, cancellationToken); - } - - protected override Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken) - { - return _messageBroker.PostMessageAsync(message, cancellationToken); - } - - protected override Task GetUserToDeleteAsync(Guid userId, CancellationToken cancellationToken) - { - return Task.FromResult(_userToDelete); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/MessageBroker.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/MessageBroker.cs deleted file mode 100644 index 594697b8a4..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/FireAndForgetDelivery/MessageBroker.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Collections.Generic; -using System.Net; -using System.Threading; -using System.Threading.Tasks; -using JsonApiDotNetCore.Errors; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.FireAndForgetDelivery -{ - public sealed class MessageBroker - { - internal IList SentMessages { get; } = new List(); - - internal bool SimulateFailure { get; set; } - - internal void Reset() - { - SimulateFailure = false; - SentMessages.Clear(); - } - - internal Task PostMessageAsync(OutgoingMessage message, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - SentMessages.Add(message); - - if (SimulateFailure) - { - throw new JsonApiException(new Error(HttpStatusCode.ServiceUnavailable) - { - Title = "Message delivery failed." - }); - } - - return Task.CompletedTask; - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/GroupCreatedContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/GroupCreatedContent.cs deleted file mode 100644 index 78e0d34b90..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/GroupCreatedContent.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; -using JetBrains.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class GroupCreatedContent : IMessageContent - { - public int FormatVersion => 1; - - public Guid GroupId { get; set; } - public string GroupName { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/GroupDeletedContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/GroupDeletedContent.cs deleted file mode 100644 index c168671ba6..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/GroupDeletedContent.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System; -using JetBrains.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class GroupDeletedContent : IMessageContent - { - public int FormatVersion => 1; - - public Guid GroupId { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/GroupRenamedContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/GroupRenamedContent.cs deleted file mode 100644 index 0da1eb58ff..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/GroupRenamedContent.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using JetBrains.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class GroupRenamedContent : IMessageContent - { - public int FormatVersion => 1; - - public Guid GroupId { get; set; } - public string BeforeGroupName { get; set; } - public string AfterGroupName { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/IMessageContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/IMessageContent.cs deleted file mode 100644 index e95b74e673..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/IMessageContent.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages -{ - public interface IMessageContent - { - // Increment when content structure changes. - int FormatVersion { get; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/OutgoingMessage.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/OutgoingMessage.cs deleted file mode 100644 index 68837f0d12..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/OutgoingMessage.cs +++ /dev/null @@ -1,33 +0,0 @@ -using JetBrains.Annotations; -using Newtonsoft.Json; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class OutgoingMessage - { - public long Id { get; set; } - public string Type { get; set; } - public int FormatVersion { get; set; } - public string Content { get; set; } - - public T GetContentAs() - where T : IMessageContent - { - string namespacePrefix = typeof(IMessageContent).Namespace; - var contentType = System.Type.GetType(namespacePrefix + "." + Type, true); - - return (T)JsonConvert.DeserializeObject(Content, contentType); - } - - public static OutgoingMessage CreateFromContent(IMessageContent content) - { - return new OutgoingMessage - { - Type = content.GetType().Name, - FormatVersion = content.FormatVersion, - Content = JsonConvert.SerializeObject(content) - }; - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserAddedToGroupContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserAddedToGroupContent.cs deleted file mode 100644 index 209f1d4035..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserAddedToGroupContent.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; -using JetBrains.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class UserAddedToGroupContent : IMessageContent - { - public int FormatVersion => 1; - - public Guid UserId { get; set; } - public Guid GroupId { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserCreatedContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserCreatedContent.cs deleted file mode 100644 index ebbffa4152..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserCreatedContent.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using JetBrains.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class UserCreatedContent : IMessageContent - { - public int FormatVersion => 1; - - public Guid UserId { get; set; } - public string UserLoginName { get; set; } - public string UserDisplayName { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserDeletedContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserDeletedContent.cs deleted file mode 100644 index 94c77c0b49..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserDeletedContent.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System; -using JetBrains.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class UserDeletedContent : IMessageContent - { - public int FormatVersion => 1; - - public Guid UserId { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserDisplayNameChangedContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserDisplayNameChangedContent.cs deleted file mode 100644 index f461de5807..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserDisplayNameChangedContent.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using JetBrains.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class UserDisplayNameChangedContent : IMessageContent - { - public int FormatVersion => 1; - - public Guid UserId { get; set; } - public string BeforeUserDisplayName { get; set; } - public string AfterUserDisplayName { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserLoginNameChangedContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserLoginNameChangedContent.cs deleted file mode 100644 index b6abe8a478..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserLoginNameChangedContent.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using JetBrains.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class UserLoginNameChangedContent : IMessageContent - { - public int FormatVersion => 1; - - public Guid UserId { get; set; } - public string BeforeUserLoginName { get; set; } - public string AfterUserLoginName { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserMovedToGroupContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserMovedToGroupContent.cs deleted file mode 100644 index 1ef56bc316..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserMovedToGroupContent.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; -using JetBrains.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class UserMovedToGroupContent : IMessageContent - { - public int FormatVersion => 1; - - public Guid UserId { get; set; } - public Guid BeforeGroupId { get; set; } - public Guid AfterGroupId { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserRemovedFromGroupContent.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserRemovedFromGroupContent.cs deleted file mode 100644 index 82cce6824e..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/Messages/UserRemovedFromGroupContent.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System; -using JetBrains.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class UserRemovedFromGroupContent : IMessageContent - { - public int FormatVersion => 1; - - public Guid UserId { get; set; } - public Guid GroupId { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs deleted file mode 100644 index cac44d1c03..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/MessagingGroupDefinition.cs +++ /dev/null @@ -1,236 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; -using Microsoft.EntityFrameworkCore; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices -{ - public abstract class MessagingGroupDefinition : JsonApiResourceDefinition - { - private readonly DbSet _userSet; - private readonly DbSet _groupSet; - private readonly ResourceDefinitionHitCounter _hitCounter; - private readonly List _pendingMessages = new List(); - - private string _beforeGroupName; - - protected MessagingGroupDefinition(IResourceGraph resourceGraph, DbSet userSet, DbSet groupSet, - ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph) - { - _userSet = userSet; - _groupSet = groupSet; - _hitCounter = hitCounter; - } - - public override Task OnPrepareWriteAsync(DomainGroup group, OperationKind operationKind, CancellationToken cancellationToken) - { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync); - - if (operationKind == OperationKind.CreateResource) - { - group.Id = Guid.NewGuid(); - } - else if (operationKind == OperationKind.UpdateResource) - { - _beforeGroupName = group.Name; - } - - return Task.CompletedTask; - } - - public override async Task OnSetToManyRelationshipAsync(DomainGroup group, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - OperationKind operationKind, CancellationToken cancellationToken) - { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToManyRelationshipAsync); - - if (hasManyRelationship.Property.Name == nameof(DomainGroup.Users)) - { - HashSet rightUserIds = rightResourceIds.Select(resource => (Guid)resource.GetTypedId()).ToHashSet(); - - List beforeUsers = await _userSet.Include(user => user.Group).Where(user => rightUserIds.Contains(user.Id)) - .ToListAsync(cancellationToken); - - foreach (DomainUser beforeUser in beforeUsers) - { - IMessageContent content = null; - - if (beforeUser.Group == null) - { - content = new UserAddedToGroupContent - { - UserId = beforeUser.Id, - GroupId = group.Id - }; - } - else if (beforeUser.Group != null && beforeUser.Group.Id != group.Id) - { - content = new UserMovedToGroupContent - { - UserId = beforeUser.Id, - BeforeGroupId = beforeUser.Group.Id, - AfterGroupId = group.Id - }; - } - - if (content != null) - { - _pendingMessages.Add(OutgoingMessage.CreateFromContent(content)); - } - } - - if (group.Users != null) - { - foreach (DomainUser userToRemoveFromGroup in group.Users.Where(user => !rightUserIds.Contains(user.Id))) - { - var message = OutgoingMessage.CreateFromContent(new UserRemovedFromGroupContent - { - UserId = userToRemoveFromGroup.Id, - GroupId = group.Id - }); - - _pendingMessages.Add(message); - } - } - } - } - - public override async Task OnAddToRelationshipAsync(Guid groupId, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - CancellationToken cancellationToken) - { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.OnAddToRelationshipAsync); - - if (hasManyRelationship.Property.Name == nameof(DomainGroup.Users)) - { - HashSet rightUserIds = rightResourceIds.Select(resource => (Guid)resource.GetTypedId()).ToHashSet(); - - List beforeUsers = await _userSet.Include(user => user.Group).Where(user => rightUserIds.Contains(user.Id)) - .ToListAsync(cancellationToken); - - foreach (DomainUser beforeUser in beforeUsers) - { - IMessageContent content = null; - - if (beforeUser.Group == null) - { - content = new UserAddedToGroupContent - { - UserId = beforeUser.Id, - GroupId = groupId - }; - } - else if (beforeUser.Group != null && beforeUser.Group.Id != groupId) - { - content = new UserMovedToGroupContent - { - UserId = beforeUser.Id, - BeforeGroupId = beforeUser.Group.Id, - AfterGroupId = groupId - }; - } - - if (content != null) - { - _pendingMessages.Add(OutgoingMessage.CreateFromContent(content)); - } - } - } - } - - public override Task OnRemoveFromRelationshipAsync(DomainGroup group, HasManyAttribute hasManyRelationship, ISet rightResourceIds, - CancellationToken cancellationToken) - { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.OnRemoveFromRelationshipAsync); - - if (hasManyRelationship.Property.Name == nameof(DomainGroup.Users)) - { - HashSet rightUserIds = rightResourceIds.Select(resource => (Guid)resource.GetTypedId()).ToHashSet(); - - foreach (DomainUser userToRemoveFromGroup in group.Users.Where(user => rightUserIds.Contains(user.Id))) - { - var message = OutgoingMessage.CreateFromContent(new UserRemovedFromGroupContent - { - UserId = userToRemoveFromGroup.Id, - GroupId = group.Id - }); - - _pendingMessages.Add(message); - } - } - - return Task.CompletedTask; - } - - protected async Task FinishWriteAsync(DomainGroup group, OperationKind operationKind, CancellationToken cancellationToken) - { - if (operationKind == OperationKind.CreateResource) - { - var message = OutgoingMessage.CreateFromContent(new GroupCreatedContent - { - GroupId = group.Id, - GroupName = group.Name - }); - - await FlushMessageAsync(message, cancellationToken); - } - else if (operationKind == OperationKind.UpdateResource) - { - if (_beforeGroupName != group.Name) - { - var message = OutgoingMessage.CreateFromContent(new GroupRenamedContent - { - GroupId = group.Id, - BeforeGroupName = _beforeGroupName, - AfterGroupName = group.Name - }); - - await FlushMessageAsync(message, cancellationToken); - } - } - else if (operationKind == OperationKind.DeleteResource) - { - DomainGroup groupToDelete = await GetGroupToDeleteAsync(group.Id, cancellationToken); - - if (groupToDelete != null) - { - foreach (DomainUser user in groupToDelete.Users) - { - var removeMessage = OutgoingMessage.CreateFromContent(new UserRemovedFromGroupContent - { - UserId = user.Id, - GroupId = group.Id - }); - - await FlushMessageAsync(removeMessage, cancellationToken); - } - } - - var deleteMessage = OutgoingMessage.CreateFromContent(new GroupDeletedContent - { - GroupId = group.Id - }); - - await FlushMessageAsync(deleteMessage, cancellationToken); - } - - foreach (OutgoingMessage nextMessage in _pendingMessages) - { - await FlushMessageAsync(nextMessage, cancellationToken); - } - } - - protected abstract Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken); - - protected virtual async Task GetGroupToDeleteAsync(Guid groupId, CancellationToken cancellationToken) - { - return await _groupSet.Include(group => group.Users).FirstOrDefaultAsync(group => group.Id == groupId, cancellationToken); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/MessagingUserDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/MessagingUserDefinition.cs deleted file mode 100644 index 2e48d9e3d7..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/MessagingUserDefinition.cs +++ /dev/null @@ -1,168 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; -using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; -using Microsoft.EntityFrameworkCore; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices -{ - public abstract class MessagingUserDefinition : JsonApiResourceDefinition - { - private readonly DbSet _userSet; - private readonly ResourceDefinitionHitCounter _hitCounter; - private readonly List _pendingMessages = new List(); - - private string _beforeLoginName; - private string _beforeDisplayName; - - protected MessagingUserDefinition(IResourceGraph resourceGraph, DbSet userSet, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph) - { - _userSet = userSet; - _hitCounter = hitCounter; - } - - public override Task OnPrepareWriteAsync(DomainUser user, OperationKind operationKind, CancellationToken cancellationToken) - { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync); - - if (operationKind == OperationKind.CreateResource) - { - user.Id = Guid.NewGuid(); - } - else if (operationKind == OperationKind.UpdateResource) - { - _beforeLoginName = user.LoginName; - _beforeDisplayName = user.DisplayName; - } - - return Task.CompletedTask; - } - - public override Task OnSetToOneRelationshipAsync(DomainUser user, HasOneAttribute hasOneRelationship, IIdentifiable rightResourceId, - OperationKind operationKind, CancellationToken cancellationToken) - { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToOneRelationshipAsync); - - if (hasOneRelationship.Property.Name == nameof(DomainUser.Group)) - { - var afterGroupId = (Guid?)rightResourceId?.GetTypedId(); - IMessageContent content = null; - - if (user.Group != null && afterGroupId == null) - { - content = new UserRemovedFromGroupContent - { - UserId = user.Id, - GroupId = user.Group.Id - }; - } - else if (user.Group == null && afterGroupId != null) - { - content = new UserAddedToGroupContent - { - UserId = user.Id, - GroupId = afterGroupId.Value - }; - } - else if (user.Group != null && afterGroupId != null && user.Group.Id != afterGroupId) - { - content = new UserMovedToGroupContent - { - UserId = user.Id, - BeforeGroupId = user.Group.Id, - AfterGroupId = afterGroupId.Value - }; - } - - if (content != null) - { - var message = OutgoingMessage.CreateFromContent(content); - _pendingMessages.Add(message); - } - } - - return Task.FromResult(rightResourceId); - } - - protected async Task FinishWriteAsync(DomainUser user, OperationKind operationKind, CancellationToken cancellationToken) - { - if (operationKind == OperationKind.CreateResource) - { - var message = OutgoingMessage.CreateFromContent(new UserCreatedContent - { - UserId = user.Id, - UserLoginName = user.LoginName, - UserDisplayName = user.DisplayName - }); - - await FlushMessageAsync(message, cancellationToken); - } - else if (operationKind == OperationKind.UpdateResource) - { - if (_beforeLoginName != user.LoginName) - { - var message = OutgoingMessage.CreateFromContent(new UserLoginNameChangedContent - { - UserId = user.Id, - BeforeUserLoginName = _beforeLoginName, - AfterUserLoginName = user.LoginName - }); - - await FlushMessageAsync(message, cancellationToken); - } - - if (_beforeDisplayName != user.DisplayName) - { - var message = OutgoingMessage.CreateFromContent(new UserDisplayNameChangedContent - { - UserId = user.Id, - BeforeUserDisplayName = _beforeDisplayName, - AfterUserDisplayName = user.DisplayName - }); - - await FlushMessageAsync(message, cancellationToken); - } - } - else if (operationKind == OperationKind.DeleteResource) - { - DomainUser userToDelete = await GetUserToDeleteAsync(user.Id, cancellationToken); - - if (userToDelete?.Group != null) - { - var removeMessage = OutgoingMessage.CreateFromContent(new UserRemovedFromGroupContent - { - UserId = user.Id, - GroupId = userToDelete.Group.Id - }); - - await FlushMessageAsync(removeMessage, cancellationToken); - } - - var deleteMessage = OutgoingMessage.CreateFromContent(new UserDeletedContent - { - UserId = user.Id - }); - - await FlushMessageAsync(deleteMessage, cancellationToken); - } - - foreach (OutgoingMessage nextMessage in _pendingMessages) - { - await FlushMessageAsync(nextMessage, cancellationToken); - } - } - - protected abstract Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken); - - protected virtual async Task GetUserToDeleteAsync(Guid userId, CancellationToken cancellationToken) - { - return await _userSet.Include(domainUser => domainUser.Group).FirstOrDefaultAsync(domainUser => domainUser.Id == userId, cancellationToken); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxDbContext.cs deleted file mode 100644 index 04edfda455..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxDbContext.cs +++ /dev/null @@ -1,19 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; -using Microsoft.EntityFrameworkCore; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.TransactionalOutboxPattern -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class OutboxDbContext : DbContext - { - public DbSet Users { get; set; } - public DbSet Groups { get; set; } - public DbSet OutboxMessages { get; set; } - - public OutboxDbContext(DbContextOptions options) - : base(options) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxGroupDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxGroupDefinition.cs deleted file mode 100644 index 61e685859b..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxGroupDefinition.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; -using Microsoft.EntityFrameworkCore; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.TransactionalOutboxPattern -{ - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class OutboxGroupDefinition : MessagingGroupDefinition - { - private readonly ResourceDefinitionHitCounter _hitCounter; - private readonly DbSet _outboxMessageSet; - - public OutboxGroupDefinition(IResourceGraph resourceGraph, OutboxDbContext dbContext, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph, dbContext.Users, dbContext.Groups, hitCounter) - { - _hitCounter = hitCounter; - _outboxMessageSet = dbContext.OutboxMessages; - } - - public override Task OnWritingAsync(DomainGroup group, OperationKind operationKind, CancellationToken cancellationToken) - { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync); - - return FinishWriteAsync(group, operationKind, cancellationToken); - } - - protected override async Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken) - { - await _outboxMessageSet.AddAsync(message, cancellationToken); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs deleted file mode 100644 index cc71a19f6d..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.Group.cs +++ /dev/null @@ -1,622 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.TransactionalOutboxPattern -{ - public sealed partial class OutboxTests - { - [Fact] - public async Task Create_group_writes_to_outbox() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - string newGroupName = _fakers.DomainGroup.Generate().Name; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - }); - - var requestBody = new - { - data = new - { - type = "domainGroups", - attributes = new - { - name = newGroupName - } - } - }; - - const string route = "/domainGroups"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["name"].Should().Be(newGroupName); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) - }, options => options.WithStrictOrdering()); - - Guid newGroupId = Guid.Parse(responseDocument.SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); - - var content = messages[0].GetContentAs(); - content.GroupId.Should().Be(newGroupId); - content.GroupName.Should().Be(newGroupName); - }); - } - - [Fact] - public async Task Create_group_with_users_writes_to_outbox() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); - - DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); - existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); - - string newGroupName = _fakers.DomainGroup.Generate().Name; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithOtherGroup); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "domainGroups", - attributes = new - { - name = newGroupName - }, - relationships = new - { - users = new - { - data = new[] - { - new - { - type = "domainUsers", - id = existingUserWithoutGroup.StringId - }, - new - { - type = "domainUsers", - id = existingUserWithOtherGroup.StringId - } - } - } - } - } - }; - - const string route = "/domainGroups"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["name"].Should().Be(newGroupName); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToManyRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) - }, options => options.WithStrictOrdering()); - - Guid newGroupId = Guid.Parse(responseDocument.SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(3); - - var content1 = messages[0].GetContentAs(); - content1.GroupId.Should().Be(newGroupId); - content1.GroupName.Should().Be(newGroupName); - - var content2 = messages[1].GetContentAs(); - content2.UserId.Should().Be(existingUserWithoutGroup.Id); - content2.GroupId.Should().Be(newGroupId); - - var content3 = messages[2].GetContentAs(); - content3.UserId.Should().Be(existingUserWithOtherGroup.Id); - content3.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); - content3.AfterGroupId.Should().Be(newGroupId); - }); - } - - [Fact] - public async Task Update_group_writes_to_outbox() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - - string newGroupName = _fakers.DomainGroup.Generate().Name; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Groups.Add(existingGroup); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "domainGroups", - id = existingGroup.StringId, - attributes = new - { - name = newGroupName - } - } - }; - - string route = "/domainGroups/" + existingGroup.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) - }, options => options.WithStrictOrdering()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); - - var content = messages[0].GetContentAs(); - content.GroupId.Should().Be(existingGroup.StringId); - content.BeforeGroupName.Should().Be(existingGroup.Name); - content.AfterGroupName.Should().Be(newGroupName); - }); - } - - [Fact] - public async Task Update_group_with_users_writes_to_outbox() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - - DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); - - DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); - existingUserWithSameGroup1.Group = existingGroup; - - DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); - existingUserWithSameGroup2.Group = existingGroup; - - DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); - existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithSameGroup1, existingUserWithSameGroup2, existingUserWithOtherGroup); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "domainGroups", - id = existingGroup.StringId, - relationships = new - { - users = new - { - data = new[] - { - new - { - type = "domainUsers", - id = existingUserWithoutGroup.StringId - }, - new - { - type = "domainUsers", - id = existingUserWithSameGroup1.StringId - }, - new - { - type = "domainUsers", - id = existingUserWithOtherGroup.StringId - } - } - } - } - } - }; - - string route = "/domainGroups/" + existingGroup.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToManyRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) - }, options => options.WithStrictOrdering()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(3); - - var content1 = messages[0].GetContentAs(); - content1.UserId.Should().Be(existingUserWithoutGroup.Id); - content1.GroupId.Should().Be(existingGroup.Id); - - var content2 = messages[1].GetContentAs(); - content2.UserId.Should().Be(existingUserWithOtherGroup.Id); - content2.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); - content2.AfterGroupId.Should().Be(existingGroup.Id); - - var content3 = messages[2].GetContentAs(); - content3.UserId.Should().Be(existingUserWithSameGroup2.Id); - content3.GroupId.Should().Be(existingGroup.Id); - }); - } - - [Fact] - public async Task Delete_group_writes_to_outbox() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Groups.Add(existingGroup); - await dbContext.SaveChangesAsync(); - }); - - string route = "/domainGroups/" + existingGroup.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) - }, options => options.WithStrictOrdering()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); - - var content = messages[0].GetContentAs(); - content.GroupId.Should().Be(existingGroup.StringId); - }); - } - - [Fact] - 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(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Groups.Add(existingGroup); - await dbContext.SaveChangesAsync(); - }); - - string route = "/domainGroups/" + existingGroup.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) - }, options => options.WithStrictOrdering()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(2); - - var content1 = messages[0].GetContentAs(); - content1.UserId.Should().Be(existingGroup.Users.ElementAt(0).Id); - content1.GroupId.Should().Be(existingGroup.StringId); - - var content2 = messages[1].GetContentAs(); - content2.GroupId.Should().Be(existingGroup.StringId); - }); - } - - [Fact] - public async Task Replace_users_in_group_writes_to_outbox() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - - DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); - - DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); - existingUserWithSameGroup1.Group = existingGroup; - - DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); - existingUserWithSameGroup2.Group = existingGroup; - - DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); - existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithSameGroup1, existingUserWithSameGroup2, existingUserWithOtherGroup); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "domainUsers", - id = existingUserWithoutGroup.StringId - }, - new - { - type = "domainUsers", - id = existingUserWithSameGroup1.StringId - }, - new - { - type = "domainUsers", - id = existingUserWithOtherGroup.StringId - } - } - }; - - string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToManyRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) - }, options => options.WithStrictOrdering()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(3); - - var content1 = messages[0].GetContentAs(); - content1.UserId.Should().Be(existingUserWithoutGroup.Id); - content1.GroupId.Should().Be(existingGroup.Id); - - var content2 = messages[1].GetContentAs(); - content2.UserId.Should().Be(existingUserWithOtherGroup.Id); - content2.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); - content2.AfterGroupId.Should().Be(existingGroup.Id); - - var content3 = messages[2].GetContentAs(); - content3.UserId.Should().Be(existingUserWithSameGroup2.Id); - content3.GroupId.Should().Be(existingGroup.Id); - }); - } - - [Fact] - public async Task Add_users_to_group_writes_to_outbox() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - - DomainUser existingUserWithoutGroup = _fakers.DomainUser.Generate(); - - DomainUser existingUserWithSameGroup = _fakers.DomainUser.Generate(); - existingUserWithSameGroup.Group = existingGroup; - - DomainUser existingUserWithOtherGroup = _fakers.DomainUser.Generate(); - existingUserWithOtherGroup.Group = _fakers.DomainGroup.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Users.AddRange(existingUserWithoutGroup, existingUserWithSameGroup, existingUserWithOtherGroup); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "domainUsers", - id = existingUserWithoutGroup.StringId - }, - new - { - type = "domainUsers", - id = existingUserWithOtherGroup.StringId - } - } - }; - - string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnAddToRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) - }, options => options.WithStrictOrdering()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(2); - - var content1 = messages[0].GetContentAs(); - content1.UserId.Should().Be(existingUserWithoutGroup.Id); - content1.GroupId.Should().Be(existingGroup.Id); - - var content2 = messages[1].GetContentAs(); - content2.UserId.Should().Be(existingUserWithOtherGroup.Id); - content2.BeforeGroupId.Should().Be(existingUserWithOtherGroup.Group.Id); - content2.AfterGroupId.Should().Be(existingGroup.Id); - }); - } - - [Fact] - public async Task Remove_users_from_group_writes_to_outbox() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - - DomainUser existingUserWithSameGroup1 = _fakers.DomainUser.Generate(); - existingUserWithSameGroup1.Group = existingGroup; - - DomainUser existingUserWithSameGroup2 = _fakers.DomainUser.Generate(); - existingUserWithSameGroup2.Group = existingGroup; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Users.AddRange(existingUserWithSameGroup1, existingUserWithSameGroup2); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "domainUsers", - id = existingUserWithSameGroup2.StringId - } - } - }; - - string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnRemoveFromRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) - }, options => options.WithStrictOrdering()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); - - var content = messages[0].GetContentAs(); - content.UserId.Should().Be(existingUserWithSameGroup2.Id); - content.GroupId.Should().Be(existingGroup.Id); - }); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs deleted file mode 100644 index d392a4fb32..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.User.cs +++ /dev/null @@ -1,687 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.TransactionalOutboxPattern -{ - public sealed partial class OutboxTests - { - [Fact] - 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; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - }); - - var requestBody = new - { - data = new - { - type = "domainUsers", - attributes = new - { - loginName = newLoginName, - displayName = newDisplayName - } - } - }; - - const string route = "/domainUsers"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["loginName"].Should().Be(newLoginName); - responseDocument.SingleData.Attributes["displayName"].Should().Be(newDisplayName); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) - }, options => options.WithStrictOrdering()); - - Guid newUserId = Guid.Parse(responseDocument.SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); - - var content = messages[0].GetContentAs(); - content.UserId.Should().Be(newUserId); - content.UserLoginName.Should().Be(newLoginName); - content.UserDisplayName.Should().Be(newDisplayName); - }); - } - - [Fact] - public async Task Create_user_in_group_writes_to_outbox() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - - string newLoginName = _fakers.DomainUser.Generate().LoginName; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Groups.Add(existingGroup); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "domainUsers", - attributes = new - { - loginName = newLoginName - }, - relationships = new - { - group = new - { - data = new - { - type = "domainGroups", - id = existingGroup.StringId - } - } - } - } - }; - - const string route = "/domainUsers"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["loginName"].Should().Be(newLoginName); - responseDocument.SingleData.Attributes["displayName"].Should().BeNull(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) - }, options => options.WithStrictOrdering()); - - Guid newUserId = Guid.Parse(responseDocument.SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(2); - - var content1 = messages[0].GetContentAs(); - content1.UserId.Should().Be(newUserId); - content1.UserLoginName.Should().Be(newLoginName); - content1.UserDisplayName.Should().BeNull(); - - var content2 = messages[1].GetContentAs(); - content2.UserId.Should().Be(newUserId); - content2.GroupId.Should().Be(existingGroup.Id); - }); - } - - [Fact] - public async Task Update_user_writes_to_outbox() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - DomainUser existingUser = _fakers.DomainUser.Generate(); - - string newLoginName = _fakers.DomainUser.Generate().LoginName; - string newDisplayName = _fakers.DomainUser.Generate().DisplayName; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Users.Add(existingUser); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "domainUsers", - id = existingUser.StringId, - attributes = new - { - loginName = newLoginName, - displayName = newDisplayName - } - } - }; - - string route = "/domainUsers/" + existingUser.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) - }, options => options.WithStrictOrdering()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(2); - - var content1 = messages[0].GetContentAs(); - content1.UserId.Should().Be(existingUser.Id); - content1.BeforeUserLoginName.Should().Be(existingUser.LoginName); - content1.AfterUserLoginName.Should().Be(newLoginName); - - var content2 = messages[1].GetContentAs(); - content2.UserId.Should().Be(existingUser.Id); - content2.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); - content2.AfterUserDisplayName.Should().Be(newDisplayName); - }); - } - - [Fact] - 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(); - - string newDisplayName = _fakers.DomainUser.Generate().DisplayName; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Users.Add(existingUser); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "domainUsers", - id = existingUser.StringId, - attributes = new - { - displayName = newDisplayName - }, - relationships = new - { - group = new - { - data = (object)null - } - } - } - }; - - string route = "/domainUsers/" + existingUser.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) - }, options => options.WithStrictOrdering()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(2); - - var content1 = messages[0].GetContentAs(); - content1.UserId.Should().Be(existingUser.Id); - content1.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); - content1.AfterUserDisplayName.Should().Be(newDisplayName); - - var content2 = messages[1].GetContentAs(); - content2.UserId.Should().Be(existingUser.Id); - content2.GroupId.Should().Be(existingUser.Group.Id); - }); - } - - [Fact] - 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(); - - string newDisplayName = _fakers.DomainUser.Generate().DisplayName; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddRange(existingUser, existingGroup); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "domainUsers", - id = existingUser.StringId, - attributes = new - { - displayName = newDisplayName - }, - relationships = new - { - group = new - { - data = new - { - type = "domainGroups", - id = existingGroup.StringId - } - } - } - } - }; - - string route = "/domainUsers/" + existingUser.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) - }, options => options.WithStrictOrdering()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(2); - - var content1 = messages[0].GetContentAs(); - content1.UserId.Should().Be(existingUser.Id); - content1.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); - content1.AfterUserDisplayName.Should().Be(newDisplayName); - - var content2 = messages[1].GetContentAs(); - content2.UserId.Should().Be(existingUser.Id); - content2.GroupId.Should().Be(existingGroup.Id); - }); - } - - [Fact] - 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(); - - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - - string newDisplayName = _fakers.DomainUser.Generate().DisplayName; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddRange(existingUser, existingGroup); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "domainUsers", - id = existingUser.StringId, - attributes = new - { - displayName = newDisplayName - }, - relationships = new - { - group = new - { - data = new - { - type = "domainGroups", - id = existingGroup.StringId - } - } - } - } - }; - - string route = "/domainUsers/" + existingUser.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) - }, options => options.WithStrictOrdering()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(2); - - var content1 = messages[0].GetContentAs(); - content1.UserId.Should().Be(existingUser.Id); - content1.BeforeUserDisplayName.Should().Be(existingUser.DisplayName); - content1.AfterUserDisplayName.Should().Be(newDisplayName); - - var content2 = messages[1].GetContentAs(); - content2.UserId.Should().Be(existingUser.Id); - content2.BeforeGroupId.Should().Be(existingUser.Group.Id); - content2.AfterGroupId.Should().Be(existingGroup.Id); - }); - } - - [Fact] - public async Task Delete_user_writes_to_outbox() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - DomainUser existingUser = _fakers.DomainUser.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Users.Add(existingUser); - await dbContext.SaveChangesAsync(); - }); - - string route = "/domainUsers/" + existingUser.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) - }, options => options.WithStrictOrdering()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); - - var content = messages[0].GetContentAs(); - content.UserId.Should().Be(existingUser.Id); - }); - } - - [Fact] - 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(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Users.Add(existingUser); - await dbContext.SaveChangesAsync(); - }); - - string route = "/domainUsers/" + existingUser.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) - }, options => options.WithStrictOrdering()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(2); - - var content1 = messages[0].GetContentAs(); - content1.UserId.Should().Be(existingUser.Id); - content1.GroupId.Should().Be(existingUser.Group.Id); - - var content2 = messages[1].GetContentAs(); - content2.UserId.Should().Be(existingUser.Id); - }); - } - - [Fact] - 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(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.Users.Add(existingUser); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = (object)null - }; - - string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) - }, options => options.WithStrictOrdering()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); - - var content = messages[0].GetContentAs(); - content.UserId.Should().Be(existingUser.Id); - content.GroupId.Should().Be(existingUser.Group.Id); - }); - } - - [Fact] - 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(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddRange(existingUser, existingGroup); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "domainGroups", - id = existingGroup.StringId - } - }; - - string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) - }, options => options.WithStrictOrdering()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); - - var content = messages[0].GetContentAs(); - content.UserId.Should().Be(existingUser.Id); - content.GroupId.Should().Be(existingGroup.Id); - }); - } - - [Fact] - 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(); - - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddRange(existingUser, existingGroup); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "domainGroups", - id = existingGroup.StringId - } - }; - - string route = $"/domainUsers/{existingUser.StringId}/relationships/group"; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnPrepareWriteAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnSetToOneRelationshipAsync), - (typeof(DomainUser), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) - }, options => options.WithStrictOrdering()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().HaveCount(1); - - var content = messages[0].GetContentAs(); - content.UserId.Should().Be(existingUser.Id); - content.BeforeGroupId.Should().Be(existingUser.Group.Id); - content.AfterGroupId.Should().Be(existingGroup.Id); - }); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.cs deleted file mode 100644 index 875af7adf9..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxTests.cs +++ /dev/null @@ -1,109 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.TransactionalOutboxPattern -{ - // Implements the Transactional Outbox Microservices pattern, described at: https://microservices.io/patterns/data/transactional-outbox.html - - public sealed partial class OutboxTests : IClassFixture, OutboxDbContext>> - { - private readonly ExampleIntegrationTestContext, OutboxDbContext> _testContext; - private readonly DomainFakers _fakers = new DomainFakers(); - - public OutboxTests(ExampleIntegrationTestContext, OutboxDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - testContext.UseController(); - - testContext.ConfigureServicesAfterStartup(services => - { - services.AddResourceDefinition(); - services.AddResourceDefinition(); - - services.AddSingleton(); - }); - - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - hitCounter.Reset(); - } - - [Fact] - public async Task Does_not_add_to_outbox_on_write_error() - { - // Arrange - var hitCounter = _testContext.Factory.Services.GetRequiredService(); - - DomainGroup existingGroup = _fakers.DomainGroup.Generate(); - - DomainUser existingUser = _fakers.DomainUser.Generate(); - - string missingUserId = Guid.NewGuid().ToString(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.AddRange(existingGroup, existingUser); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "domainUsers", - id = existingUser.StringId - }, - new - { - type = "domainUsers", - id = missingUserId - } - } - }; - - string route = $"/domainGroups/{existingGroup.StringId}/relationships/users"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 'domainUsers' with ID '{missingUserId}' in relationship 'users' does not exist."); - - hitCounter.HitExtensibilityPoints.Should().BeEquivalentTo(new[] - { - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnAddToRelationshipAsync), - (typeof(DomainGroup), ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync) - }, options => options.WithStrictOrdering()); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - List messages = await dbContext.OutboxMessages.OrderBy(message => message.Id).ToListAsync(); - messages.Should().BeEmpty(); - }); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxUserDefinition.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxUserDefinition.cs deleted file mode 100644 index 88b8a2e865..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Microservices/TransactionalOutboxPattern/OutboxUserDefinition.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Threading; -using System.Threading.Tasks; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.Messages; -using Microsoft.EntityFrameworkCore; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.Microservices.TransactionalOutboxPattern -{ - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class OutboxUserDefinition : MessagingUserDefinition - { - private readonly ResourceDefinitionHitCounter _hitCounter; - private readonly DbSet _outboxMessageSet; - - public OutboxUserDefinition(IResourceGraph resourceGraph, OutboxDbContext dbContext, ResourceDefinitionHitCounter hitCounter) - : base(resourceGraph, dbContext.Users, hitCounter) - { - _hitCounter = hitCounter; - _outboxMessageSet = dbContext.OutboxMessages; - } - - public override Task OnWritingAsync(DomainUser user, OperationKind operationKind, CancellationToken cancellationToken) - { - _hitCounter.TrackInvocation(ResourceDefinitionHitCounter.ExtensibilityPoint.OnWritingAsync); - - return FinishWriteAsync(user, operationKind, cancellationToken); - } - - protected override async Task FlushMessageAsync(OutgoingMessage message, CancellationToken cancellationToken) - { - await _outboxMessageSet.AddAsync(message, cancellationToken); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/IHasTenant.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/IHasTenant.cs deleted file mode 100644 index 055f86aef8..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/IHasTenant.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System; -using JetBrains.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public interface IHasTenant - { - Guid TenantId { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/ITenantProvider.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/ITenantProvider.cs deleted file mode 100644 index 2a524cc06f..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/ITenantProvider.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy -{ - public interface ITenantProvider - { - // An implementation would obtain the tenant ID from the request, for example from the incoming - // authentication token, a custom HTTP header, the route or a query string parameter. - Guid TenantId { get; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyDbContext.cs deleted file mode 100644 index d45206c35c..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyDbContext.cs +++ /dev/null @@ -1,36 +0,0 @@ -using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore; - -// @formatter:wrap_chained_method_calls chop_always - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class MultiTenancyDbContext : DbContext - { - private readonly ITenantProvider _tenantProvider; - - public DbSet WebShops { get; set; } - public DbSet WebProducts { get; set; } - - public MultiTenancyDbContext(DbContextOptions options, ITenantProvider tenantProvider) - : base(options) - { - _tenantProvider = tenantProvider; - } - - protected override void OnModelCreating(ModelBuilder builder) - { - builder.Entity() - .HasMany(webShop => webShop.Products) - .WithOne(webProduct => webProduct.Shop) - .IsRequired(); - - builder.Entity() - .HasQueryFilter(webShop => webShop.TenantId == _tenantProvider.TenantId); - - builder.Entity() - .HasQueryFilter(webProduct => webProduct.Shop.TenantId == _tenantProvider.TenantId); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyFakers.cs deleted file mode 100644 index 6f1e0cf57b..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyFakers.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System; -using Bogus; -using TestBuildingBlocks; - -// @formatter:wrap_chained_method_calls chop_always -// @formatter:keep_existing_linebreaks true - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy -{ - internal sealed class MultiTenancyFakers : FakerContainer - { - private readonly Lazy> _lazyWebShopFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(webShop => webShop.Url, faker => faker.Internet.Url())); - - private readonly Lazy> _lazyWebProductFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(webProduct => webProduct.Name, faker => faker.Commerce.ProductName()) - .RuleFor(webProduct => webProduct.Price, faker => faker.Finance.Amount())); - - public Faker WebShop => _lazyWebShopFaker.Value; - public Faker WebProduct => _lazyWebProductFaker.Value; - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs deleted file mode 100644 index 190b15d019..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs +++ /dev/null @@ -1,1040 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Serialization.Objects; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.AspNetCore.Http; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy -{ - public sealed class MultiTenancyTests : IClassFixture, MultiTenancyDbContext>> - { - private static readonly Guid ThisTenantId = RouteTenantProvider.TenantRegistry["nld"]; - private static readonly Guid OtherTenantId = RouteTenantProvider.TenantRegistry["ita"]; - - private readonly ExampleIntegrationTestContext, MultiTenancyDbContext> _testContext; - private readonly MultiTenancyFakers _fakers = new MultiTenancyFakers(); - - public MultiTenancyTests(ExampleIntegrationTestContext, MultiTenancyDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - testContext.UseController(); - - testContext.ConfigureServicesBeforeStartup(services => - { - services.AddSingleton(); - services.AddScoped(); - }); - - testContext.ConfigureServicesAfterStartup(services => - { - services.AddResourceService>(); - services.AddResourceService>(); - }); - - var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); - options.UseRelativeLinks = true; - } - - [Fact] - public async Task Get_primary_resources_hides_other_tenants() - { - // Arrange - List shops = _fakers.WebShop.Generate(2); - shops[0].TenantId = OtherTenantId; - shops[1].TenantId = ThisTenantId; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.WebShops.AddRange(shops); - await dbContext.SaveChangesAsync(); - }); - - const string route = "/nld/shops"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Id.Should().Be(shops[1].StringId); - } - - [Fact] - public async Task Filter_on_primary_resources_hides_other_tenants() - { - // Arrange - List shops = _fakers.WebShop.Generate(2); - shops[0].TenantId = OtherTenantId; - shops[0].Products = _fakers.WebProduct.Generate(1); - - shops[1].TenantId = ThisTenantId; - shops[1].Products = _fakers.WebProduct.Generate(1); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.WebShops.AddRange(shops); - await dbContext.SaveChangesAsync(); - }); - - const string route = "/nld/shops?filter=has(products)"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Id.Should().Be(shops[1].StringId); - } - - [Fact] - public async Task Get_primary_resources_with_include_hides_other_tenants() - { - // Arrange - List shops = _fakers.WebShop.Generate(2); - shops[0].TenantId = OtherTenantId; - shops[0].Products = _fakers.WebProduct.Generate(1); - - shops[1].TenantId = ThisTenantId; - shops[1].Products = _fakers.WebProduct.Generate(1); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.WebShops.AddRange(shops); - await dbContext.SaveChangesAsync(); - }); - - const string route = "/nld/shops?include=products"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Type.Should().Be("webShops"); - responseDocument.ManyData[0].Id.Should().Be(shops[1].StringId); - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Type.Should().Be("webProducts"); - responseDocument.Included[0].Id.Should().Be(shops[1].Products[0].StringId); - } - - [Fact] - public async Task Cannot_get_primary_resource_by_ID_from_other_tenant() - { - // Arrange - WebShop shop = _fakers.WebShop.Generate(); - shop.TenantId = OtherTenantId; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WebShops.Add(shop); - await dbContext.SaveChangesAsync(); - }); - - string route = "/nld/shops/" + shop.StringId; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 'webShops' with ID '{shop.StringId}' does not exist."); - } - - [Fact] - public async Task Cannot_get_secondary_resources_from_other_parent_tenant() - { - // Arrange - WebShop shop = _fakers.WebShop.Generate(); - shop.TenantId = OtherTenantId; - shop.Products = _fakers.WebProduct.Generate(1); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WebShops.Add(shop); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/nld/shops/{shop.StringId}/products"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 'webShops' with ID '{shop.StringId}' does not exist."); - } - - [Fact] - public async Task Cannot_get_secondary_resource_from_other_parent_tenant() - { - // Arrange - WebProduct product = _fakers.WebProduct.Generate(); - product.Shop = _fakers.WebShop.Generate(); - product.Shop.TenantId = OtherTenantId; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WebProducts.Add(product); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/nld/products/{product.StringId}/shop"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 'webProducts' with ID '{product.StringId}' does not exist."); - } - - [Fact] - public async Task Cannot_get_HasMany_relationship_for_other_parent_tenant() - { - // Arrange - WebShop shop = _fakers.WebShop.Generate(); - shop.TenantId = OtherTenantId; - shop.Products = _fakers.WebProduct.Generate(1); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WebShops.Add(shop); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/nld/shops/{shop.StringId}/relationships/products"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 'webShops' with ID '{shop.StringId}' does not exist."); - } - - [Fact] - public async Task Cannot_get_HasOne_relationship_for_other_parent_tenant() - { - // Arrange - WebProduct product = _fakers.WebProduct.Generate(); - product.Shop = _fakers.WebShop.Generate(); - product.Shop.TenantId = OtherTenantId; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WebProducts.Add(product); - await dbContext.SaveChangesAsync(); - }); - - string route = $"/nld/products/{product.StringId}/relationships/shop"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 'webProducts' with ID '{product.StringId}' does not exist."); - } - - [Fact] - public async Task Can_create_resource() - { - // Arrange - string newShopUrl = _fakers.WebShop.Generate().Url; - - var requestBody = new - { - data = new - { - type = "webShops", - attributes = new - { - url = newShopUrl - } - } - }; - - const string route = "/nld/shops"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Attributes["url"].Should().Be(newShopUrl); - responseDocument.SingleData.Relationships.Should().NotBeNull(); - - int newShopId = int.Parse(responseDocument.SingleData.Id); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - WebShop shopInDatabase = await dbContext.WebShops.IgnoreQueryFilters().FirstWithIdAsync(newShopId); - - shopInDatabase.Url.Should().Be(newShopUrl); - shopInDatabase.TenantId.Should().Be(ThisTenantId); - }); - } - - [Fact] - public async Task Cannot_create_resource_with_HasMany_relationship_to_other_tenant() - { - // Arrange - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); - existingProduct.Shop.TenantId = OtherTenantId; - - string newShopUrl = _fakers.WebShop.Generate().Url; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WebProducts.Add(existingProduct); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "webShops", - attributes = new - { - url = newShopUrl - }, - relationships = new - { - products = new - { - data = new[] - { - new - { - type = "webProducts", - id = existingProduct.StringId - } - } - } - } - } - }; - - const string route = "/nld/shops"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 'webProducts' with ID '{existingProduct.StringId}' in relationship 'products' does not exist."); - } - - [Fact] - public async Task Cannot_create_resource_with_HasOne_relationship_to_other_tenant() - { - // Arrange - WebShop existingShop = _fakers.WebShop.Generate(); - existingShop.TenantId = OtherTenantId; - - string newProductName = _fakers.WebProduct.Generate().Name; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WebShops.Add(existingShop); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "webProducts", - attributes = new - { - name = newProductName - }, - relationships = new - { - shop = new - { - data = new - { - type = "webShops", - id = existingShop.StringId - } - } - } - } - }; - - const string route = "/nld/products"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 'webShops' with ID '{existingShop.StringId}' in relationship 'shop' does not exist."); - } - - [Fact] - public async Task Can_update_resource() - { - // Arrange - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); - existingProduct.Shop.TenantId = ThisTenantId; - - string newProductName = _fakers.WebProduct.Generate().Name; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WebProducts.Add(existingProduct); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "webProducts", - id = existingProduct.StringId, - attributes = new - { - name = newProductName - } - } - }; - - string route = "/nld/products/" + existingProduct.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - WebProduct productInDatabase = await dbContext.WebProducts.IgnoreQueryFilters().FirstWithIdAsync(existingProduct.Id); - - productInDatabase.Name.Should().Be(newProductName); - productInDatabase.Price.Should().Be(existingProduct.Price); - }); - } - - [Fact] - public async Task Cannot_update_resource_from_other_tenant() - { - // Arrange - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); - existingProduct.Shop.TenantId = OtherTenantId; - - string newProductName = _fakers.WebProduct.Generate().Name; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WebProducts.Add(existingProduct); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "webProducts", - id = existingProduct.StringId, - attributes = new - { - name = newProductName - } - } - }; - - string route = "/nld/products/" + existingProduct.StringId; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 'webProducts' with ID '{existingProduct.StringId}' does not exist."); - } - - [Fact] - public async Task Cannot_update_resource_with_HasMany_relationship_to_other_tenant() - { - // Arrange - WebShop existingShop = _fakers.WebShop.Generate(); - existingShop.TenantId = ThisTenantId; - - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); - existingProduct.Shop.TenantId = OtherTenantId; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingShop, existingProduct); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "webShops", - id = existingShop.StringId, - relationships = new - { - products = new - { - data = new[] - { - new - { - type = "webProducts", - id = existingProduct.StringId - } - } - } - } - } - }; - - string route = "/nld/shops/" + existingShop.StringId; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 'webProducts' with ID '{existingProduct.StringId}' in relationship 'products' does not exist."); - } - - [Fact] - public async Task Cannot_update_resource_with_HasOne_relationship_to_other_tenant() - { - // Arrange - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); - existingProduct.Shop.TenantId = ThisTenantId; - - WebShop existingShop = _fakers.WebShop.Generate(); - existingShop.TenantId = OtherTenantId; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingProduct, existingShop); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "webProducts", - id = existingProduct.StringId, - relationships = new - { - shop = new - { - data = new - { - type = "webShops", - id = existingShop.StringId - } - } - } - } - }; - - string route = "/nld/products/" + existingProduct.StringId; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 'webShops' with ID '{existingShop.StringId}' in relationship 'shop' does not exist."); - } - - [Fact] - public async Task Cannot_update_HasMany_relationship_for_other_parent_tenant() - { - // Arrange - WebShop existingShop = _fakers.WebShop.Generate(); - existingShop.TenantId = OtherTenantId; - existingShop.Products = _fakers.WebProduct.Generate(1); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WebShops.Add(existingShop); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new object[0] - }; - - string route = $"/nld/shops/{existingShop.StringId}/relationships/products"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 'webShops' with ID '{existingShop.StringId}' does not exist."); - } - - [Fact] - public async Task Cannot_update_HasMany_relationship_to_other_tenant() - { - // Arrange - WebShop existingShop = _fakers.WebShop.Generate(); - existingShop.TenantId = ThisTenantId; - - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); - existingProduct.Shop.TenantId = OtherTenantId; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingShop, existingProduct); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "webProducts", - id = existingProduct.StringId - } - } - }; - - string route = $"/nld/shops/{existingShop.StringId}/relationships/products"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 'webProducts' with ID '{existingProduct.StringId}' in relationship 'products' does not exist."); - } - - [Fact] - public async Task Cannot_update_HasOne_relationship_for_other_parent_tenant() - { - // Arrange - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); - existingProduct.Shop.TenantId = OtherTenantId; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WebProducts.Add(existingProduct); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = (object)null - }; - - string route = $"/nld/products/{existingProduct.StringId}/relationships/shop"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 'webProducts' with ID '{existingProduct.StringId}' does not exist."); - } - - [Fact] - public async Task Cannot_update_HasOne_relationship_to_other_tenant() - { - // Arrange - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); - existingProduct.Shop.TenantId = ThisTenantId; - - WebShop existingShop = _fakers.WebShop.Generate(); - existingShop.TenantId = OtherTenantId; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingProduct, existingShop); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "webShops", - id = existingShop.StringId - } - }; - - string route = $"/nld/products/{existingProduct.StringId}/relationships/shop"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 'webShops' with ID '{existingShop.StringId}' in relationship 'shop' does not exist."); - } - - [Fact] - public async Task Cannot_add_to_ToMany_relationship_for_other_parent_tenant() - { - // Arrange - WebShop existingShop = _fakers.WebShop.Generate(); - existingShop.TenantId = OtherTenantId; - - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); - existingProduct.Shop.TenantId = ThisTenantId; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingShop, existingProduct); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "webProducts", - id = existingProduct.StringId - } - } - }; - - string route = $"/nld/shops/{existingShop.StringId}/relationships/products"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 'webShops' with ID '{existingShop.StringId}' does not exist."); - } - - [Fact] - public async Task Cannot_add_to_ToMany_relationship_with_other_tenant() - { - WebShop existingShop = _fakers.WebShop.Generate(); - existingShop.TenantId = ThisTenantId; - - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); - existingProduct.Shop.TenantId = OtherTenantId; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.AddRange(existingShop, existingProduct); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "webProducts", - id = existingProduct.StringId - } - } - }; - - string route = $"/nld/shops/{existingShop.StringId}/relationships/products"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 'webProducts' with ID '{existingProduct.StringId}' in relationship 'products' does not exist."); - } - - [Fact] - public async Task Cannot_remove_from_ToMany_relationship_for_other_parent_tenant() - { - // Arrange - WebShop existingShop = _fakers.WebShop.Generate(); - existingShop.TenantId = OtherTenantId; - existingShop.Products = _fakers.WebProduct.Generate(1); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WebShops.Add(existingShop); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new[] - { - new - { - type = "webProducts", - id = existingShop.Products[0].StringId - } - } - }; - - string route = $"/nld/shops/{existingShop.StringId}/relationships/products"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 'webShops' with ID '{existingShop.StringId}' does not exist."); - } - - [Fact] - public async Task Can_delete_resource() - { - // Arrange - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); - existingProduct.Shop.TenantId = ThisTenantId; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WebProducts.Add(existingProduct); - await dbContext.SaveChangesAsync(); - }); - - string route = "/nld/products/" + existingProduct.StringId; - - // Act - (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NoContent); - - responseDocument.Should().BeEmpty(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - WebProduct productInDatabase = await dbContext.WebProducts.IgnoreQueryFilters().FirstWithIdOrDefaultAsync(existingProduct.Id); - - productInDatabase.Should().BeNull(); - }); - } - - [Fact] - public async Task Cannot_delete_resource_from_other_tenant() - { - // Arrange - WebProduct existingProduct = _fakers.WebProduct.Generate(); - existingProduct.Shop = _fakers.WebShop.Generate(); - existingProduct.Shop.TenantId = OtherTenantId; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.WebProducts.Add(existingProduct); - await dbContext.SaveChangesAsync(); - }); - - string route = "/nld/products/" + existingProduct.StringId; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteDeleteAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.NotFound); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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 'webProducts' with ID '{existingProduct.StringId}' does not exist."); - } - - [Fact] - public async Task Renders_links_with_tenant_route_parameter() - { - // Arrange - WebShop shop = _fakers.WebShop.Generate(); - shop.TenantId = ThisTenantId; - shop.Products = _fakers.WebProduct.Generate(1); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.WebShops.Add(shop); - await dbContext.SaveChangesAsync(); - }); - - const string route = "/nld/shops?include=products"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.Links.Self.Should().Be(route); - responseDocument.Links.Related.Should().BeNull(); - responseDocument.Links.First.Should().Be(route); - responseDocument.Links.Last.Should().BeNull(); - responseDocument.Links.Prev.Should().BeNull(); - responseDocument.Links.Next.Should().BeNull(); - - string shopLink = $"/nld/shops/{shop.StringId}"; - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Links.Self.Should().Be(shopLink); - responseDocument.ManyData[0].Relationships["products"].Links.Self.Should().Be(shopLink + "/relationships/products"); - responseDocument.ManyData[0].Relationships["products"].Links.Related.Should().Be(shopLink + "/products"); - - string productLink = $"/nld/products/{shop.Products[0].StringId}"; - - responseDocument.Included.Should().HaveCount(1); - responseDocument.Included[0].Links.Self.Should().Be(productLink); - responseDocument.Included[0].Relationships["shop"].Links.Self.Should().Be(productLink + "/relationships/shop"); - responseDocument.Included[0].Relationships["shop"].Links.Related.Should().Be(productLink + "/shop"); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs deleted file mode 100644 index 68a8d78c1f..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenantResourceService.cs +++ /dev/null @@ -1,100 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Hooks; -using JsonApiDotNetCore.Middleware; -using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.Repositories; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy -{ - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public class MultiTenantResourceService : JsonApiResourceService - where TResource : class, IIdentifiable - { - private readonly ITenantProvider _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, IResourceHookExecutorFacade hookExecutor) - : base(repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, hookExecutor) - { - _tenantProvider = tenantProvider; - } - - protected override async Task InitializeResourceAsync(TResource resourceForDatabase, CancellationToken cancellationToken) - { - await base.InitializeResourceAsync(resourceForDatabase, cancellationToken); - - if (ResourceHasTenant) - { - Guid tenantId = _tenantProvider.TenantId; - - var resourceWithTenant = (IHasTenant)resourceForDatabase; - resourceWithTenant.TenantId = tenantId; - } - } - - // To optimize performance, the default resource service does not always fetch all resources on write operations. - // We do that here, to assure everything belongs to the active tenant. On mismatch, a 404 error is thrown. - - public override async Task CreateAsync(TResource resource, CancellationToken cancellationToken) - { - await AssertResourcesToAssignInRelationshipsExistAsync(resource, cancellationToken); - - return await base.CreateAsync(resource, cancellationToken); - } - - public override async Task UpdateAsync(TId id, TResource resource, CancellationToken cancellationToken) - { - await AssertResourcesToAssignInRelationshipsExistAsync(resource, cancellationToken); - - return await base.UpdateAsync(id, resource, cancellationToken); - } - - public override async Task SetRelationshipAsync(TId primaryId, string relationshipName, object secondaryResourceIds, - CancellationToken cancellationToken) - { - await AssertRightResourcesExistAsync(secondaryResourceIds, cancellationToken); - - await base.SetRelationshipAsync(primaryId, relationshipName, secondaryResourceIds, cancellationToken); - } - - public override async Task AddToToManyRelationshipAsync(TId primaryId, string relationshipName, ISet secondaryResourceIds, - CancellationToken cancellationToken) - { - _ = await GetPrimaryResourceByIdAsync(primaryId, TopFieldSelection.OnlyIdAttribute, cancellationToken); - await AssertRightResourcesExistAsync(secondaryResourceIds, cancellationToken); - - await base.AddToToManyRelationshipAsync(primaryId, relationshipName, secondaryResourceIds, cancellationToken); - } - - public override async Task DeleteAsync(TId id, CancellationToken cancellationToken) - { - _ = await GetPrimaryResourceByIdAsync(id, TopFieldSelection.OnlyIdAttribute, cancellationToken); - - await base.DeleteAsync(id, cancellationToken); - } - } - - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public class MultiTenantResourceService : MultiTenantResourceService, IResourceService - where TResource : class, IIdentifiable - { - public MultiTenantResourceService(ITenantProvider tenantProvider, IResourceRepositoryAccessor repositoryAccessor, - IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext, IJsonApiOptions options, ILoggerFactory loggerFactory, - IJsonApiRequest request, IResourceChangeTracker resourceChangeTracker, IResourceHookExecutorFacade hookExecutor) - : base(tenantProvider, repositoryAccessor, queryLayerComposer, paginationContext, options, loggerFactory, request, resourceChangeTracker, - hookExecutor) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/RouteTenantProvider.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/RouteTenantProvider.cs deleted file mode 100644 index 56c806530f..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/RouteTenantProvider.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System; -using System.Collections.Generic; -using Microsoft.AspNetCore.Http; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy -{ - internal sealed class RouteTenantProvider : 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 - { - ["nld"] = Guid.NewGuid(), - ["ita"] = Guid.NewGuid() - }; - - private readonly IHttpContextAccessor _httpContextAccessor; - - public Guid TenantId - { - get - { - string countryCode = (string)_httpContextAccessor.HttpContext.Request.RouteValues["countryCode"]; - return TenantRegistry.ContainsKey(countryCode) ? TenantRegistry[countryCode] : Guid.Empty; - } - } - - public RouteTenantProvider(IHttpContextAccessor httpContextAccessor) - { - _httpContextAccessor = httpContextAccessor; - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebProduct.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebProduct.cs deleted file mode 100644 index afb75e8ceb..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebProduct.cs +++ /dev/null @@ -1,19 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class WebProduct : Identifiable - { - [Attr] - public string Name { get; set; } - - [Attr] - public decimal Price { get; set; } - - [HasOne] - public WebShop Shop { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebProductsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebProductsController.cs deleted file mode 100644 index 1eaee35a11..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebProductsController.cs +++ /dev/null @@ -1,19 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Controllers.Annotations; -using JsonApiDotNetCore.Services; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy -{ - [DisableRoutingConvention] - [Route("{countryCode}/products")] - public sealed class WebProductsController : JsonApiController - { - public WebProductsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebShop.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebShop.cs deleted file mode 100644 index 8620924234..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebShop.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class WebShop : Identifiable, IHasTenant - { - [Attr] - public string Url { get; set; } - - public Guid TenantId { get; set; } - - [HasMany] - public IList Products { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebShopsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebShopsController.cs deleted file mode 100644 index c17ec48ec7..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebShopsController.cs +++ /dev/null @@ -1,19 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Controllers.Annotations; -using JsonApiDotNetCore.Services; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy -{ - [DisableRoutingConvention] - [Route("{countryCode}/shops")] - public sealed class WebShopsController : JsonApiController - { - public WebShopsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/DivingBoard.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/DivingBoard.cs deleted file mode 100644 index fc23994b25..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/DivingBoard.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.ComponentModel.DataAnnotations; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NamingConventions -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class DivingBoard : Identifiable - { - [Attr] - [Required] - [Range(1, 20)] - public decimal HeightInMeters { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/DivingBoardsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/DivingBoardsController.cs deleted file mode 100644 index 0e055f84b0..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/DivingBoardsController.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NamingConventions -{ - public sealed class DivingBoardsController : JsonApiController - { - public DivingBoardsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingConventionStartup.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingConventionStartup.cs deleted file mode 100644 index b6e762d46b..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingConventionStartup.cs +++ /dev/null @@ -1,28 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCoreExampleTests.Startups; -using Microsoft.EntityFrameworkCore; -using Newtonsoft.Json.Serialization; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NamingConventions -{ - [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] - public sealed class KebabCasingConventionStartup : TestableStartup - where TDbContext : DbContext - { - protected override void SetJsonApiOptions(JsonApiOptions options) - { - base.SetJsonApiOptions(options); - - options.Namespace = "public-api"; - options.UseRelativeLinks = true; - options.IncludeTotalResourceCount = true; - options.ValidateModelState = true; - - options.SerializerSettings.ContractResolver = new DefaultContractResolver - { - NamingStrategy = new KebabCaseNamingStrategy() - }; - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingTests.cs deleted file mode 100644 index 9d9bd9feb1..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/KebabCasingTests.cs +++ /dev/null @@ -1,204 +0,0 @@ -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCore.Serialization.Objects; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NamingConventions -{ - public sealed class KebabCasingTests : IClassFixture, SwimmingDbContext>> - { - private readonly ExampleIntegrationTestContext, SwimmingDbContext> _testContext; - private readonly SwimmingFakers _fakers = new SwimmingFakers(); - - public KebabCasingTests(ExampleIntegrationTestContext, SwimmingDbContext> testContext) - { - _testContext = testContext; - - testContext.UseController(); - testContext.UseController(); - } - - [Fact] - public async Task Can_get_resources_with_include() - { - // Arrange - List pools = _fakers.SwimmingPool.Generate(2); - pools[1].DivingBoards = _fakers.DivingBoard.Generate(1); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - await dbContext.ClearTableAsync(); - dbContext.SwimmingPools.AddRange(pools); - await dbContext.SaveChangesAsync(); - }); - - const string route = "/public-api/swimming-pools?include=diving-boards"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(2); - responseDocument.ManyData.Should().OnlyContain(resourceObject => resourceObject.Type == "swimming-pools"); - responseDocument.ManyData.Should().OnlyContain(resourceObject => resourceObject.Attributes.ContainsKey("is-indoor")); - responseDocument.ManyData.Should().OnlyContain(resourceObject => resourceObject.Relationships.ContainsKey("water-slides")); - responseDocument.ManyData.Should().OnlyContain(resourceObject => resourceObject.Relationships.ContainsKey("diving-boards")); - - 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["height-in-meters"].As().Should().BeApproximately(pools[1].DivingBoards[0].HeightInMeters); - responseDocument.Included[0].Relationships.Should().BeNull(); - responseDocument.Included[0].Links.Self.Should().Be($"/public-api/diving-boards/{pools[1].DivingBoards[0].StringId}"); - - responseDocument.Meta["total-resources"].Should().Be(2); - } - - [Fact] - public async Task Can_filter_secondary_resources_with_sparse_fieldset() - { - // Arrange - SwimmingPool pool = _fakers.SwimmingPool.Generate(); - pool.WaterSlides = _fakers.WaterSlide.Generate(2); - pool.WaterSlides[0].LengthInMeters = 1; - pool.WaterSlides[1].LengthInMeters = 5; - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.SwimmingPools.Add(pool); - 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"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - - responseDocument.ManyData.Should().HaveCount(1); - responseDocument.ManyData[0].Type.Should().Be("water-slides"); - responseDocument.ManyData[0].Id.Should().Be(pool.WaterSlides[1].StringId); - responseDocument.ManyData[0].Attributes.Should().HaveCount(1); - } - - [Fact] - public async Task Can_create_resource() - { - // Arrange - SwimmingPool newPool = _fakers.SwimmingPool.Generate(); - - var requestBody = new - { - data = new - { - type = "swimming-pools", - attributes = new Dictionary - { - ["is-indoor"] = newPool.IsIndoor - } - } - }; - - const string route = "/public-api/swimming-pools"; - - // Act - (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.Created); - - responseDocument.SingleData.Should().NotBeNull(); - responseDocument.SingleData.Type.Should().Be("swimming-pools"); - responseDocument.SingleData.Attributes["is-indoor"].Should().Be(newPool.IsIndoor); - - int newPoolId = int.Parse(responseDocument.SingleData.Id); - string poolLink = route + $"/{newPoolId}"; - - responseDocument.SingleData.Relationships.Should().NotBeEmpty(); - responseDocument.SingleData.Relationships["water-slides"].Links.Self.Should().Be(poolLink + "/relationships/water-slides"); - responseDocument.SingleData.Relationships["water-slides"].Links.Related.Should().Be(poolLink + "/water-slides"); - responseDocument.SingleData.Relationships["diving-boards"].Links.Self.Should().Be(poolLink + "/relationships/diving-boards"); - responseDocument.SingleData.Relationships["diving-boards"].Links.Related.Should().Be(poolLink + "/diving-boards"); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - SwimmingPool poolInDatabase = await dbContext.SwimmingPools.FirstWithIdAsync(newPoolId); - - poolInDatabase.IsIndoor.Should().Be(newPool.IsIndoor); - }); - } - - [Fact] - public async Task Applies_casing_convention_on_error_stack_trace() - { - // Arrange - const string requestBody = "{ \"data\": {"; - - const string route = "/public-api/swimming-pools"; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error error = responseDocument.Errors[0]; - error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - error.Title.Should().Be("Failed to deserialize request body."); - error.Meta.Data.Should().ContainKey("stack-trace"); - } - - [Fact] - public async Task Applies_casing_convention_on_source_pointer_from_ModelState() - { - // Arrange - DivingBoard existingBoard = _fakers.DivingBoard.Generate(); - - await _testContext.RunOnDatabaseAsync(async dbContext => - { - dbContext.DivingBoards.Add(existingBoard); - await dbContext.SaveChangesAsync(); - }); - - var requestBody = new - { - data = new - { - type = "diving-boards", - id = existingBoard.StringId, - attributes = new Dictionary - { - ["height-in-meters"] = -1 - } - } - }; - - string route = "/public-api/diving-boards/" + existingBoard.StringId; - - // Act - (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.UnprocessableEntity); - - responseDocument.Errors.Should().HaveCount(1); - - Error 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.Pointer.Should().Be("/data/attributes/height-in-meters"); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingDbContext.cs deleted file mode 100644 index 2722b91cc9..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingDbContext.cs +++ /dev/null @@ -1,18 +0,0 @@ -using JetBrains.Annotations; -using Microsoft.EntityFrameworkCore; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NamingConventions -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class SwimmingDbContext : DbContext - { - public DbSet SwimmingPools { get; set; } - public DbSet WaterSlides { get; set; } - public DbSet DivingBoards { get; set; } - - public SwimmingDbContext(DbContextOptions options) - : base(options) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingFakers.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingFakers.cs deleted file mode 100644 index 13debc6ca7..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingFakers.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using Bogus; -using TestBuildingBlocks; - -// @formatter:wrap_chained_method_calls chop_always -// @formatter:keep_existing_linebreaks true - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NamingConventions -{ - internal sealed class SwimmingFakers : FakerContainer - { - private readonly Lazy> _lazySwimmingPoolFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(swimmingPool => swimmingPool.IsIndoor, faker => faker.Random.Bool())); - - private readonly Lazy> _lazyWaterSlideFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(waterSlide => waterSlide.LengthInMeters, faker => faker.Random.Decimal(3, 100))); - - private readonly Lazy> _lazyDivingBoardFaker = new Lazy>(() => - new Faker() - .UseSeed(GetFakerSeed()) - .RuleFor(divingBoard => divingBoard.HeightInMeters, faker => faker.Random.Decimal(1, 15))); - - public Faker SwimmingPool => _lazySwimmingPoolFaker.Value; - public Faker WaterSlide => _lazyWaterSlideFaker.Value; - public Faker DivingBoard => _lazyDivingBoardFaker.Value; - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingPool.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingPool.cs deleted file mode 100644 index 324b984522..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingPool.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NamingConventions -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class SwimmingPool : Identifiable - { - [Attr] - public bool IsIndoor { get; set; } - - [HasMany] - public IList WaterSlides { get; set; } - - [HasMany] - public IList DivingBoards { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingPoolsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingPoolsController.cs deleted file mode 100644 index 760227f2ff..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/SwimmingPoolsController.cs +++ /dev/null @@ -1,15 +0,0 @@ -using JsonApiDotNetCore.Configuration; -using JsonApiDotNetCore.Controllers; -using JsonApiDotNetCore.Services; -using Microsoft.Extensions.Logging; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NamingConventions -{ - public sealed class SwimmingPoolsController : JsonApiController - { - public SwimmingPoolsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) - : base(options, loggerFactory, resourceService) - { - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/WaterSlide.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/WaterSlide.cs deleted file mode 100644 index b9007dfe98..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NamingConventions/WaterSlide.cs +++ /dev/null @@ -1,13 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NamingConventions -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class WaterSlide : Identifiable - { - [Attr] - public decimal LengthInMeters { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs deleted file mode 100644 index 19934d7fc3..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/NonJsonApiControllers/NonJsonApiControllerTests.cs +++ /dev/null @@ -1,154 +0,0 @@ -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Threading.Tasks; -using FluentAssertions; -using JsonApiDotNetCoreExample.Startups; -using Microsoft.AspNetCore.Mvc.Testing; -using TestBuildingBlocks; -using Xunit; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.NonJsonApiControllers -{ - public sealed class NonJsonApiControllerTests : IClassFixture> - { - private readonly WebApplicationFactory _factory; - - public NonJsonApiControllerTests(WebApplicationFactory factory) - { - _factory = factory; - } - - [Fact] - public async Task Get_skips_middleware_and_formatters() - { - // Arrange - using var request = new HttpRequestMessage(HttpMethod.Get, "/NonJsonApi"); - - HttpClient client = _factory.CreateClient(); - - // Act - HttpResponseMessage httpResponse = await client.SendAsync(request); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - httpResponse.Content.Headers.ContentType.ToString().Should().Be("application/json; charset=utf-8"); - - string responseText = await httpResponse.Content.ReadAsStringAsync(); - responseText.Should().Be("[\"Welcome!\"]"); - } - - [Fact] - public async Task Post_skips_middleware_and_formatters() - { - // Arrange - using var request = new HttpRequestMessage(HttpMethod.Post, "/NonJsonApi") - { - Content = new StringContent("Jack") - { - Headers = - { - ContentType = new MediaTypeHeaderValue("text/plain") - } - } - }; - - HttpClient client = _factory.CreateClient(); - - // Act - HttpResponseMessage httpResponse = await client.SendAsync(request); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); - - string responseText = await httpResponse.Content.ReadAsStringAsync(); - responseText.Should().Be("Hello, Jack"); - } - - [Fact] - public async Task Post_skips_error_handler() - { - // Arrange - using var request = new HttpRequestMessage(HttpMethod.Post, "/NonJsonApi"); - - HttpClient client = _factory.CreateClient(); - - // Act - HttpResponseMessage httpResponse = await client.SendAsync(request); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.BadRequest); - httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); - - string responseText = await httpResponse.Content.ReadAsStringAsync(); - responseText.Should().Be("Please send your name."); - } - - [Fact] - public async Task Put_skips_middleware_and_formatters() - { - // Arrange - using var request = new HttpRequestMessage(HttpMethod.Put, "/NonJsonApi") - { - Content = new StringContent("\"Jane\"") - { - Headers = - { - ContentType = new MediaTypeHeaderValue("application/json") - } - } - }; - - HttpClient client = _factory.CreateClient(); - - // Act - HttpResponseMessage httpResponse = await client.SendAsync(request); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); - - string responseText = await httpResponse.Content.ReadAsStringAsync(); - responseText.Should().Be("Hi, Jane"); - } - - [Fact] - public async Task Patch_skips_middleware_and_formatters() - { - // Arrange - using var request = new HttpRequestMessage(HttpMethod.Patch, "/NonJsonApi?name=Janice"); - - HttpClient client = _factory.CreateClient(); - - // Act - HttpResponseMessage httpResponse = await client.SendAsync(request); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); - - string responseText = await httpResponse.Content.ReadAsStringAsync(); - responseText.Should().Be("Good day, Janice"); - } - - [Fact] - public async Task Delete_skips_middleware_and_formatters() - { - // Arrange - using var request = new HttpRequestMessage(HttpMethod.Delete, "/NonJsonApi"); - - HttpClient client = _factory.CreateClient(); - - // Act - HttpResponseMessage httpResponse = await client.SendAsync(request); - - // Assert - httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); - httpResponse.Content.Headers.ContentType.ToString().Should().Be("text/plain; charset=utf-8"); - - string responseText = await httpResponse.Content.ReadAsStringAsync(); - responseText.Should().Be("Bye."); - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/AccountPreferences.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/AccountPreferences.cs deleted file mode 100644 index f623cf82d9..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/AccountPreferences.cs +++ /dev/null @@ -1,13 +0,0 @@ -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class AccountPreferences : Identifiable - { - [Attr] - public bool UseDarkTheme { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Appointment.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Appointment.cs deleted file mode 100644 index d7a6f84e9a..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Appointment.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Appointment : Identifiable - { - [Attr] - public string Title { get; set; } - - [Attr] - public DateTimeOffset StartTime { get; set; } - - [Attr] - public DateTimeOffset EndTime { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Blog.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Blog.cs deleted file mode 100644 index 6bcd37d005..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/Blog.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; -using System.Collections.Generic; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class Blog : Identifiable - { - [Attr] - public string Title { get; set; } - - [Attr] - public string PlatformName { get; set; } - - [Attr(Capabilities = AttrCapabilities.All & ~(AttrCapabilities.AllowCreate | AttrCapabilities.AllowChange))] - public bool ShowAdvertisements => PlatformName.EndsWith("(using free account)", StringComparison.Ordinal); - - [HasMany] - public IList Posts { get; set; } - - [HasOne] - public WebAccount Owner { get; set; } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/BlogPost.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/BlogPost.cs deleted file mode 100644 index 8805bda98a..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/QueryStrings/BlogPost.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations.Schema; -using JetBrains.Annotations; -using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Resources.Annotations; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.QueryStrings -{ - [UsedImplicitly(ImplicitUseTargetFlags.Members)] - public sealed class BlogPost : Identifiable - { - [Attr] - public string Caption { get; set; } - - [Attr] - public string Url { get; set; } - - [HasOne] - public WebAccount Author { get; set; } - - [HasOne] - public WebAccount Reviewer { get; set; } - - [NotMapped] - [HasManyThrough(nameof(BlogPostLabels))] - public ISet