diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 4ceebfab3b..13ec979314 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -15,7 +15,7 @@ ] }, "dotnet-reportgenerator-globaltool": { - "version": "5.2.1", + "version": "5.2.2", "commands": [ "reportgenerator" ] diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 4205b1ceec..e7d3ce2494 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -39,7 +39,7 @@ When you are creating an enhancement suggestion, please include as many details - **Use a clear and descriptive title** for the issue to identify the suggestion. - **Provide a step-by-step description of the suggested enhancement** in as many details as possible. -- **Provide specific examples to demonstrate the usage.** Include copy/pasteable snippets which you use in those examples, as [Markdown code blocks](https://docs.github.com/en/github/writing-on-github/creating-and-highlighting-code-blocks). +- **Provide specific examples to demonstrate the usage.** Include copy/pasteable snippets which you use in those examples as [Markdown code blocks](https://docs.github.com/en/github/writing-on-github/creating-and-highlighting-code-blocks). - **Describe the current behavior and explain which behavior you expected to see instead** and why. - **Explain why this enhancement would be useful** to most users and isn't something that can or should be implemented in your API project directly. - **Verify that your enhancement does not conflict** with the [JSON:API specification](https://jsonapi.org/). @@ -56,7 +56,7 @@ Please follow these steps to have your contribution considered by the maintainer - Follow all instructions in the template. Don't forget to add tests and update documentation. - After you submit your pull request, verify that all status checks are passing. In release builds, all compiler warnings are treated as errors, so you should address them before push. -We use [CSharpGuidelines](https://csharpcodingguidelines.com/) as our coding standard (with a few minor exceptions). Coding style is validated during PR build, where we inject an extra settings layer that promotes various suggestions to warning level. This ensures a high-quality codebase without interfering too much when editing code. +We use [CSharpGuidelines](https://csharpcodingguidelines.com/) as our coding standard. Coding style is validated during PR build, where we inject an extra settings layer that promotes various IDE suggestions to warning level. This ensures a high-quality codebase without interfering too much while editing code. You can run the following [PowerShell scripts](https://github.com/PowerShell/PowerShell/releases) locally: - `pwsh ./inspectcode.ps1`: Scans the code for style violations and opens the result in your web browser. - `pwsh ./cleanupcode.ps1 [branch-name-or-commit-hash]`: Reformats the codebase to match with our configured style, optionally only changed files since the specified branch (usually master). @@ -86,13 +86,39 @@ public sealed class AppDbContext : DbContext } ``` +### Pull request workflow + +Please follow the steps and guidelines below for a smooth experience. + +Authors: +- When opening a new pull request, create it in **Draft** mode. +- After you've completed the work *and* all checks are green, click the **Ready for review** button. + - If you have permissions to do so, ask a team member for review. +- Once the review has started, don't force-push anymore. +- When you've addressed feedback from a conversation, mark it with a thumbs-up or add a some text. +- Don't close a conversation you didn't start. The creator closes it after verifying the concern has been addressed. +- Apply suggestions in a batch, instead of individual commits (to minimize email notifications). +- Re-request review when you're ready for another round. +- If you want to clean up your commits before merge, let the reviewer know in time. This is optional. + +Reviewers: +- If you're unable to review within a few days, let the author know what to expect. +- Use **Start a review** instead of **Add single comment** (to minimize email notifications). +- Consider to use suggestions (the ± button). +- Don't close a conversation you didn't start. Close the ones you opened after verifying the concern has been addressed. +- Once approved, use a merge commit only if all commits are clean. Otherwise, squash them into a single commit. + A commit is considered clean when: + - It is properly documented and covers all aspects of an isolated change (code, style, tests, docs). + - Checking out the commit results in a green build. + - Having this commit show up in the history is helpful (and can potentially be reverted). + ## Creating a release (for maintainers) - Verify documentation is up-to-date -- Bump the package version in Directory.Build.props +- Bump the package version in `Directory.Build.props` - Create a GitHub release -- Update https://github.com/json-api-dotnet/JsonApiDotNetCore.MongoDb to consume the new version and release -- Create a new branch in https://github.com/json-api-dotnet/MigrationGuide and update README.md in master +- Update [JsonApiDotNetCore.MongoDb](https://github.com/json-api-dotnet/JsonApiDotNetCore.MongoDb) to consume the new version and release +- Create a new branch in [MigrationGuide](https://github.com/json-api-dotnet/MigrationGuide) and update README.md in master, if major version change ## Backporting and hotfixes (for maintainers) @@ -101,7 +127,7 @@ public sealed class AppDbContext : DbContext git checkout tags/v2.5.1 -b release/2.5.2 ``` - Cherrypick the merge commit: `git cherry-pick {git commit SHA}` -- Bump the package version in Directory.Build.props +- Bump the package version in `Directory.Build.props` - Make any other compatibility, documentation, or tooling related changes - Push the branch to origin and verify the build - Once the build is verified, create a GitHub release, tagging the release branch diff --git a/docs/home/index.html b/docs/home/index.html index 2c3e57849b..582eb7f619 100644 --- a/docs/home/index.html +++ b/docs/home/index.html @@ -9,7 +9,7 @@ - + @@ -22,7 +22,10 @@
- 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. + 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.
We strive to eliminate as much boilerplate as possible by offering out-of-the-box features such as sorting, filtering and pagination.
The following features are supported, from HTTP all the way down to the database
Perform compound filtering using the filter
query string parameter
Order resources on one or multiple attributes using the sort
query string parameter
Leverage the benefits of paginated resources with the page
query string parameter
Side-load related resources of nested relationships using the include
query string parameter
Configure permissions, such as view/create/change/sort/filter of attributes and relationships
+Configure permissions, such as viewing, creating, modifying, sorting and filtering of attributes and relationships
Validate incoming requests using built-in ASP.NET Core ModelState
validation, which works seamlessly with partial updates
Validate incoming requests using built-in ASP.NET Model Validation, which works seamlessly with partial updates
Use various extensibility points to intercept and run custom code, besides just model annotations
#nullable enable
@@ -179,16 +183,16 @@ Resource
-
+
Request
GET /articles?filter=contains(summary,'web')&sort=-lastModifiedAt&fields[articles]=title,summary&include=author HTTP/1.1
-
+
-
+
Response
{
@@ -259,15 +263,15 @@ Response
Sponsors
-
-
+
+
-
-
+
+
diff --git a/docs/usage/writing/creating.md b/docs/usage/writing/creating.md
index ba0a21d52b..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.
diff --git a/package-versions.props b/package-versions.props
index 89a550247d..7ade7107e3 100644
--- a/package-versions.props
+++ b/package-versions.props
@@ -12,7 +12,7 @@
0.13.*
1.0.*
35.2.*
- 4.8.*
+ 4.9.*
6.0.*
2.1.*
6.12.*
diff --git a/src/Examples/DapperExample/TranslationToSql/DataModel/FromEntitiesDataModelService.cs b/src/Examples/DapperExample/TranslationToSql/DataModel/FromEntitiesDataModelService.cs
index b0ba38f3a5..711ad8517c 100644
--- a/src/Examples/DapperExample/TranslationToSql/DataModel/FromEntitiesDataModelService.cs
+++ b/src/Examples/DapperExample/TranslationToSql/DataModel/FromEntitiesDataModelService.cs
@@ -38,12 +38,12 @@ public void Initialize(DbContext dbContext)
Initialize();
}
- private void ScanForeignKeys(IModel entityModel)
+ private void ScanForeignKeys(IReadOnlyModel entityModel)
{
foreach (RelationshipAttribute relationship in ResourceGraph.GetResourceTypes().SelectMany(resourceType => resourceType.Relationships))
{
- IEntityType? leftEntityType = entityModel.FindEntityType(relationship.LeftType.ClrType);
- INavigation? navigation = leftEntityType?.FindNavigation(relationship.Property.Name);
+ IReadOnlyEntityType? leftEntityType = entityModel.FindEntityType(relationship.LeftType.ClrType);
+ IReadOnlyNavigation? navigation = leftEntityType?.FindNavigation(relationship.Property.Name);
if (navigation != null)
{
@@ -57,7 +57,7 @@ private void ScanForeignKeys(IModel entityModel)
}
}
- private void ScanColumnNullability(IModel entityModel)
+ private void ScanColumnNullability(IReadOnlyModel entityModel)
{
foreach (ResourceType resourceType in ResourceGraph.GetResourceTypes())
{
@@ -65,15 +65,15 @@ private void ScanColumnNullability(IModel entityModel)
}
}
- private void ScanColumnNullability(ResourceType resourceType, IModel entityModel)
+ private void ScanColumnNullability(ResourceType resourceType, IReadOnlyModel entityModel)
{
- IEntityType? entityType = entityModel.FindEntityType(resourceType.ClrType);
+ IReadOnlyEntityType? entityType = entityModel.FindEntityType(resourceType.ClrType);
if (entityType != null)
{
foreach (AttrAttribute attribute in resourceType.Attributes)
{
- IProperty? property = entityType.FindProperty(attribute.Property.Name);
+ IReadOnlyProperty? property = entityType.FindProperty(attribute.Property.Name);
if (property != null)
{
diff --git a/src/Examples/NoEntityFrameworkExample/Data/InMemoryModel.cs b/src/Examples/NoEntityFrameworkExample/Data/InMemoryModel.cs
deleted file mode 100644
index c81aa07b8f..0000000000
--- a/src/Examples/NoEntityFrameworkExample/Data/InMemoryModel.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-using System.Reflection;
-using JsonApiDotNetCore.Configuration;
-using Microsoft.EntityFrameworkCore.Metadata;
-
-namespace NoEntityFrameworkExample.Data;
-
-internal sealed class InMemoryModel : RuntimeModel
-{
- public InMemoryModel(IResourceGraph resourceGraph)
- {
- foreach (ResourceType resourceType in resourceGraph.GetResourceTypes())
- {
- RuntimeEntityType entityType = AddEntityType(resourceType.ClrType.FullName!, resourceType.ClrType);
- SetEntityProperties(entityType, resourceType);
- }
- }
-
- private static void SetEntityProperties(RuntimeEntityType entityType, ResourceType resourceType)
- {
- foreach (PropertyInfo property in resourceType.ClrType.GetProperties())
- {
- entityType.AddProperty(property.Name, property.PropertyType, property);
- }
- }
-}
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/Program.cs b/src/Examples/NoEntityFrameworkExample/Program.cs
index 8546e939e8..f21d116e5f 100755
--- a/src/Examples/NoEntityFrameworkExample/Program.cs
+++ b/src/Examples/NoEntityFrameworkExample/Program.cs
@@ -1,5 +1,6 @@
using JsonApiDotNetCore.Configuration;
using NoEntityFrameworkExample;
+using NoEntityFrameworkExample.Data;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
@@ -20,6 +21,12 @@
#endif
}, discovery => discovery.AddCurrentAssembly());
+builder.Services.AddSingleton(serviceProvider =>
+{
+ var resourceGraph = serviceProvider.GetRequiredService();
+ return resourceGraph.ToEntityModel();
+});
+
WebApplication app = builder.Build();
// Configure the HTTP request pipeline.
diff --git a/src/Examples/NoEntityFrameworkExample/QueryLayerToLinqConverter.cs b/src/Examples/NoEntityFrameworkExample/QueryLayerToLinqConverter.cs
index 29d5f999e9..a67a694aef 100644
--- a/src/Examples/NoEntityFrameworkExample/QueryLayerToLinqConverter.cs
+++ b/src/Examples/NoEntityFrameworkExample/QueryLayerToLinqConverter.cs
@@ -7,9 +7,9 @@
namespace NoEntityFrameworkExample;
-internal sealed class QueryLayerToLinqConverter(IModel model, IQueryableBuilder queryableBuilder)
+internal sealed class QueryLayerToLinqConverter(IReadOnlyModel entityModel, IQueryableBuilder queryableBuilder)
{
- private readonly IModel _model = model;
+ private readonly IReadOnlyModel _entityModel = entityModel;
private readonly IQueryableBuilder _queryableBuilder = queryableBuilder;
public IEnumerable ApplyQueryLayer(QueryLayer queryLayer, IEnumerable resources)
@@ -21,7 +21,7 @@ public IEnumerable ApplyQueryLayer(QueryLayer queryLayer,
// Convert QueryLayer into LINQ expression.
IQueryable source = ((IEnumerable)resources).AsQueryable();
- var context = QueryableBuilderContext.CreateRoot(source, typeof(Enumerable), _model, null);
+ 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:
diff --git a/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs b/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs
index 243b484a9b..9d0852ad7f 100644
--- a/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs
+++ b/src/Examples/NoEntityFrameworkExample/Repositories/InMemoryResourceRepository.cs
@@ -4,7 +4,7 @@
using JsonApiDotNetCore.Queries.QueryableBuilding;
using JsonApiDotNetCore.Repositories;
using JsonApiDotNetCore.Resources;
-using NoEntityFrameworkExample.Data;
+using Microsoft.EntityFrameworkCore.Metadata;
namespace NoEntityFrameworkExample.Repositories;
@@ -19,19 +19,12 @@ namespace NoEntityFrameworkExample.Repositories;
///
/// The resource identifier type.
///
-public abstract class InMemoryResourceRepository : IResourceReadRepository
+public abstract class InMemoryResourceRepository(IResourceGraph resourceGraph, IQueryableBuilder queryableBuilder, IReadOnlyModel entityModel)
+ : IResourceReadRepository
where TResource : class, IIdentifiable
{
- private readonly ResourceType _resourceType;
- private readonly QueryLayerToLinqConverter _queryLayerToLinqConverter;
-
- protected InMemoryResourceRepository(IResourceGraph resourceGraph, IQueryableBuilder queryableBuilder)
- {
- _resourceType = resourceGraph.GetResourceType();
-
- var model = new InMemoryModel(resourceGraph);
- _queryLayerToLinqConverter = new QueryLayerToLinqConverter(model, queryableBuilder);
- }
+ private readonly ResourceType _resourceType = resourceGraph.GetResourceType();
+ private readonly QueryLayerToLinqConverter _queryLayerToLinqConverter = new(entityModel, queryableBuilder);
///
public Task> GetAsync(QueryLayer queryLayer, CancellationToken cancellationToken)
diff --git a/src/Examples/NoEntityFrameworkExample/Repositories/PersonRepository.cs b/src/Examples/NoEntityFrameworkExample/Repositories/PersonRepository.cs
index 897af592b7..8e2725379c 100644
--- a/src/Examples/NoEntityFrameworkExample/Repositories/PersonRepository.cs
+++ b/src/Examples/NoEntityFrameworkExample/Repositories/PersonRepository.cs
@@ -1,14 +1,15 @@
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)
- : InMemoryResourceRepository(resourceGraph, queryableBuilder)
+public sealed class PersonRepository(IResourceGraph resourceGraph, IQueryableBuilder queryableBuilder, IReadOnlyModel entityModel)
+ : InMemoryResourceRepository(resourceGraph, queryableBuilder, entityModel)
{
protected override IEnumerable GetDataSource()
{
diff --git a/src/Examples/NoEntityFrameworkExample/Repositories/TagRepository.cs b/src/Examples/NoEntityFrameworkExample/Repositories/TagRepository.cs
index 30658fb68d..81a28ed6bc 100644
--- a/src/Examples/NoEntityFrameworkExample/Repositories/TagRepository.cs
+++ b/src/Examples/NoEntityFrameworkExample/Repositories/TagRepository.cs
@@ -1,14 +1,15 @@
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)
- : InMemoryResourceRepository(resourceGraph, queryableBuilder)
+public sealed class TagRepository(IResourceGraph resourceGraph, IQueryableBuilder queryableBuilder, IReadOnlyModel entityModel)
+ : InMemoryResourceRepository(resourceGraph, queryableBuilder, entityModel)
{
protected override IEnumerable GetDataSource()
{
diff --git a/src/Examples/NoEntityFrameworkExample/Repositories/TodoItemRepository.cs b/src/Examples/NoEntityFrameworkExample/Repositories/TodoItemRepository.cs
index 41774b0c8f..335d7c5c5a 100644
--- a/src/Examples/NoEntityFrameworkExample/Repositories/TodoItemRepository.cs
+++ b/src/Examples/NoEntityFrameworkExample/Repositories/TodoItemRepository.cs
@@ -1,14 +1,15 @@
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)
- : InMemoryResourceRepository(resourceGraph, queryableBuilder)
+public sealed class TodoItemRepository(IResourceGraph resourceGraph, IQueryableBuilder queryableBuilder, IReadOnlyModel entityModel)
+ : InMemoryResourceRepository(resourceGraph, queryableBuilder, entityModel)
{
protected override IEnumerable GetDataSource()
{
diff --git a/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs b/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs
index 67c7a4138c..e9b37560fc 100644
--- a/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs
+++ b/src/Examples/NoEntityFrameworkExample/Services/InMemoryResourceService.cs
@@ -7,7 +7,7 @@
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Resources.Annotations;
using JsonApiDotNetCore.Services;
-using NoEntityFrameworkExample.Data;
+using Microsoft.EntityFrameworkCore.Metadata;
namespace NoEntityFrameworkExample.Services;
@@ -30,32 +30,19 @@ namespace NoEntityFrameworkExample.Services;
///
/// The resource identifier type.
///
-public abstract class InMemoryResourceService : IResourceQueryService
+public abstract 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;
- private readonly IQueryLayerComposer _queryLayerComposer;
- private readonly IPaginationContext _paginationContext;
- private readonly IEnumerable _constraintProviders;
- private readonly ILogger> _logger;
- private readonly ResourceType _resourceType;
- private readonly QueryLayerToLinqConverter _queryLayerToLinqConverter;
-
- protected InMemoryResourceService(IJsonApiOptions options, IResourceGraph resourceGraph, IQueryLayerComposer queryLayerComposer,
- IPaginationContext paginationContext, IEnumerable constraintProviders, IQueryableBuilder queryableBuilder,
- ILoggerFactory loggerFactory)
- {
- _options = options;
- _queryLayerComposer = queryLayerComposer;
- _paginationContext = paginationContext;
- _constraintProviders = constraintProviders;
-
- _logger = loggerFactory.CreateLogger>();
- _resourceType = resourceGraph.GetResourceType();
-
- var model = new InMemoryModel(resourceGraph);
- _queryLayerToLinqConverter = new QueryLayerToLinqConverter(model, queryableBuilder);
- }
+ private readonly IJsonApiOptions _options = options;
+ private readonly IQueryLayerComposer _queryLayerComposer = queryLayerComposer;
+ private readonly IPaginationContext _paginationContext = paginationContext;
+ private readonly IEnumerable _constraintProviders = constraintProviders;
+ private readonly ILogger> _logger = loggerFactory.CreateLogger>();
+ private readonly ResourceType _resourceType = resourceGraph.GetResourceType();
+ private readonly QueryLayerToLinqConverter _queryLayerToLinqConverter = new(entityModel, queryableBuilder);
///
public Task> GetAsync(CancellationToken cancellationToken)
diff --git a/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs b/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs
index 294d23978c..d38cca9c94 100644
--- a/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs
+++ b/src/Examples/NoEntityFrameworkExample/Services/TodoItemService.cs
@@ -3,6 +3,7 @@
using JsonApiDotNetCore.Queries;
using JsonApiDotNetCore.Queries.QueryableBuilding;
using JsonApiDotNetCore.Resources;
+using Microsoft.EntityFrameworkCore.Metadata;
using NoEntityFrameworkExample.Data;
using NoEntityFrameworkExample.Models;
@@ -11,8 +12,8 @@ namespace NoEntityFrameworkExample.Services;
[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
public sealed class TodoItemService(
IJsonApiOptions options, IResourceGraph resourceGraph, IQueryLayerComposer queryLayerComposer, IPaginationContext paginationContext,
- IEnumerable constraintProviders, IQueryableBuilder queryableBuilder, ILoggerFactory loggerFactory)
- : InMemoryResourceService(options, resourceGraph, queryLayerComposer, paginationContext, constraintProviders, queryableBuilder,
+ IEnumerable constraintProviders, IQueryableBuilder queryableBuilder, IReadOnlyModel entityModel, ILoggerFactory loggerFactory)
+ : InMemoryResourceService(options, resourceGraph, queryLayerComposer, paginationContext, constraintProviders, queryableBuilder, entityModel,
loggerFactory)
{
protected override IEnumerable GetDataSource(ResourceType resourceType)
diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs
index 2004178ccd..2973a664f6 100644
--- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs
+++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs
@@ -168,6 +168,7 @@ public void ConfigureServiceContainer(ICollection dbContextTypes)
_services.TryAddScoped();
_services.TryAddScoped();
_services.TryAddScoped();
+ _services.TryAddSingleton();
}
private void AddMiddlewareLayer()
diff --git a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs
index 7dcf44b1f4..12242c29ff 100644
--- a/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs
+++ b/src/JsonApiDotNetCore/Queries/Expressions/QueryExpressionVisitor.cs
@@ -5,6 +5,12 @@ namespace JsonApiDotNetCore.Queries.Expressions;
///
/// Implements the visitor design pattern that enables traversing a tree.
///
+///
+/// The type to use for passing custom state between visit methods.
+///
+///
+/// The type that is returned from visit methods.
+///
[PublicAPI]
public abstract class QueryExpressionVisitor
{
diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs
index 42dcf80428..05cccf7943 100644
--- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs
+++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryClauseBuilderContext.cs
@@ -29,7 +29,7 @@ public sealed class QueryClauseBuilderContext
///
/// The Entity Framework Core entity model.
///
- public IModel EntityModel { get; }
+ public IReadOnlyModel EntityModel { get; }
///
/// Used to produce unique names for lambda parameters.
@@ -51,7 +51,7 @@ public sealed class QueryClauseBuilderContext
///
public object? State { get; }
- public QueryClauseBuilderContext(Expression source, ResourceType resourceType, Type extensionType, IModel entityModel,
+ public QueryClauseBuilderContext(Expression source, ResourceType resourceType, Type extensionType, IReadOnlyModel entityModel,
LambdaScopeFactory lambdaScopeFactory, LambdaScope lambdaScope, IQueryableBuilder queryableBuilder, object? state)
{
ArgumentGuard.NotNull(source);
diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilderContext.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilderContext.cs
index 4659cca875..358990fdc7 100644
--- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilderContext.cs
+++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/QueryableBuilderContext.cs
@@ -29,7 +29,7 @@ public sealed class QueryableBuilderContext
///
/// The Entity Framework Core entity model.
///
- public IModel EntityModel { get; }
+ public IReadOnlyModel EntityModel { get; }
///
/// Used to produce unique names for lambda parameters.
@@ -41,7 +41,7 @@ public sealed class QueryableBuilderContext
///
public object? State { get; }
- public QueryableBuilderContext(Expression source, Type elementType, Type extensionType, IModel entityModel, LambdaScopeFactory lambdaScopeFactory,
+ public QueryableBuilderContext(Expression source, Type elementType, Type extensionType, IReadOnlyModel entityModel, LambdaScopeFactory lambdaScopeFactory,
object? state)
{
ArgumentGuard.NotNull(source);
@@ -58,15 +58,15 @@ public QueryableBuilderContext(Expression source, Type elementType, Type extensi
State = state;
}
- public static QueryableBuilderContext CreateRoot(IQueryable source, Type extensionType, IModel model, object? state)
+ public static QueryableBuilderContext CreateRoot(IQueryable source, Type extensionType, IReadOnlyModel entityModel, object? state)
{
ArgumentGuard.NotNull(source);
ArgumentGuard.NotNull(extensionType);
- ArgumentGuard.NotNull(model);
+ ArgumentGuard.NotNull(entityModel);
var lambdaScopeFactory = new LambdaScopeFactory();
- return new QueryableBuilderContext(source.Expression, source.ElementType, extensionType, model, lambdaScopeFactory, state);
+ return new QueryableBuilderContext(source.Expression, source.ElementType, extensionType, entityModel, lambdaScopeFactory, state);
}
public QueryClauseBuilderContext CreateClauseContext(IQueryableBuilder queryableBuilder, Expression source, ResourceType resourceType,
diff --git a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs
index d1113946ad..c67b785d89 100644
--- a/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs
+++ b/src/JsonApiDotNetCore/Queries/QueryableBuilding/SelectClauseBuilder.cs
@@ -40,8 +40,8 @@ public virtual Expression ApplySelect(FieldSelection selection, QueryClauseBuild
private Expression CreateLambdaBodyInitializer(FieldSelection selection, ResourceType resourceType, LambdaScope lambdaScope,
bool lambdaAccessorRequiresTestForNull, QueryClauseBuilderContext context)
{
- IEntityType entityType = context.EntityModel.FindEntityType(resourceType.ClrType)!;
- IEntityType[] concreteEntityTypes = entityType.GetConcreteDerivedTypesInclusive().ToArray();
+ IReadOnlyEntityType entityType = context.EntityModel.FindEntityType(resourceType.ClrType)!;
+ IReadOnlyEntityType[] concreteEntityTypes = entityType.GetConcreteDerivedTypesInclusive().ToArray();
Expression bodyInitializer = concreteEntityTypes.Length > 1
? CreateLambdaBodyInitializerForTypeHierarchy(selection, resourceType, concreteEntityTypes, lambdaScope, context)
@@ -56,12 +56,12 @@ private Expression CreateLambdaBodyInitializer(FieldSelection selection, Resourc
}
private Expression CreateLambdaBodyInitializerForTypeHierarchy(FieldSelection selection, ResourceType baseResourceType,
- IEnumerable concreteEntityTypes, LambdaScope lambdaScope, QueryClauseBuilderContext context)
+ IEnumerable concreteEntityTypes, LambdaScope lambdaScope, QueryClauseBuilderContext context)
{
IReadOnlySet resourceTypes = selection.GetResourceTypes();
Expression rootCondition = lambdaScope.Accessor;
- foreach (IEntityType entityType in concreteEntityTypes)
+ foreach (IReadOnlyEntityType entityType in concreteEntityTypes)
{
ResourceType? resourceType = resourceTypes.SingleOrDefault(type => type.ClrType == entityType.ClrType);
@@ -115,7 +115,7 @@ private Expression CreateLambdaBodyInitializerForSingleType(FieldSelection selec
}
private static ICollection ToPropertySelectors(FieldSelectors fieldSelectors, ResourceType resourceType, Type elementType,
- IModel entityModel)
+ IReadOnlyModel entityModel)
{
var propertySelectors = new Dictionary();
@@ -134,17 +134,18 @@ private static ICollection ToPropertySelectors(FieldSelectors
return propertySelectors.Values;
}
- private static void IncludeAllScalarProperties(Type elementType, Dictionary propertySelectors, IModel entityModel)
+ private static void IncludeAllScalarProperties(Type elementType, Dictionary propertySelectors, IReadOnlyModel entityModel)
{
- IEntityType entityType = entityModel.GetEntityTypes().Single(type => type.ClrType == elementType);
+ IReadOnlyEntityType entityType = entityModel.GetEntityTypes().Single(type => type.ClrType == elementType);
- foreach (IProperty property in entityType.GetProperties().Where(property => !property.IsShadowProperty()))
+ foreach (IReadOnlyProperty property in entityType.GetProperties().Where(property => !property.IsShadowProperty()))
{
var propertySelector = new PropertySelector(property.PropertyInfo!);
IncludeWritableProperty(propertySelector, propertySelectors);
}
- foreach (INavigation navigation in entityType.GetNavigations().Where(navigation => navigation.ForeignKey.IsOwnership && !navigation.IsShadowProperty()))
+ foreach (IReadOnlyNavigation navigation in entityType.GetNavigations()
+ .Where(navigation => navigation.ForeignKey.IsOwnership && !navigation.IsShadowProperty()))
{
var propertySelector = new PropertySelector(navigation.PropertyInfo!);
IncludeWritableProperty(propertySelector, propertySelectors);
diff --git a/src/JsonApiDotNetCore/QueryStrings/QueryStringReader.cs b/src/JsonApiDotNetCore/QueryStrings/QueryStringReader.cs
index c412d03c94..e3c944a045 100644
--- a/src/JsonApiDotNetCore/QueryStrings/QueryStringReader.cs
+++ b/src/JsonApiDotNetCore/QueryStrings/QueryStringReader.cs
@@ -38,6 +38,11 @@ public void ReadAll(DisableQueryStringAttribute? disableQueryStringAttribute)
foreach ((string parameterName, StringValues parameterValue) in _queryStringAccessor.Query)
{
+ if (parameterName.Length == 0)
+ {
+ continue;
+ }
+
IQueryStringParameterReader? reader = _parameterReaders.FirstOrDefault(nextReader => nextReader.CanRead(parameterName));
if (reader != null)
diff --git a/src/JsonApiDotNetCore/Repositories/DbContextResolver.cs b/src/JsonApiDotNetCore/Repositories/DbContextResolver.cs
index 794eecc6f2..bc275a96c1 100644
--- a/src/JsonApiDotNetCore/Repositories/DbContextResolver.cs
+++ b/src/JsonApiDotNetCore/Repositories/DbContextResolver.cs
@@ -4,6 +4,9 @@
namespace JsonApiDotNetCore.Repositories;
///
+///
+/// The type of the to resolve.
+///
[PublicAPI]
public sealed class DbContextResolver : IDbContextResolver
where TDbContext : DbContext
diff --git a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs
index 4c3314ddab..a5f083932b 100644
--- a/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs
+++ b/src/JsonApiDotNetCore/Repositories/EntityFrameworkCoreRepository.cs
@@ -21,6 +21,12 @@ 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
diff --git a/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs b/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs
index 1f69735f92..13404f2759 100644
--- a/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs
+++ b/src/JsonApiDotNetCore/Resources/IResourceChangeTracker.cs
@@ -3,6 +3,9 @@ namespace JsonApiDotNetCore.Resources;
///
/// Used to determine whether additional changes to a resource (side effects), not specified in a POST or PATCH request, have been applied.
///
+///
+/// The resource type.
+///
public interface IResourceChangeTracker
where TResource : class, IIdentifiable
{
diff --git a/src/JsonApiDotNetCore/Resources/QueryStringParameterHandlers.cs b/src/JsonApiDotNetCore/Resources/QueryStringParameterHandlers.cs
index b48c46e26e..7fe8970b53 100644
--- a/src/JsonApiDotNetCore/Resources/QueryStringParameterHandlers.cs
+++ b/src/JsonApiDotNetCore/Resources/QueryStringParameterHandlers.cs
@@ -6,4 +6,7 @@ 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/Serialization/Objects/SingleOrManyData.cs b/src/JsonApiDotNetCore/Serialization/Objects/SingleOrManyData.cs
index 1126f84f26..99884d61e6 100644
--- a/src/JsonApiDotNetCore/Serialization/Objects/SingleOrManyData.cs
+++ b/src/JsonApiDotNetCore/Serialization/Objects/SingleOrManyData.cs
@@ -9,6 +9,9 @@ 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
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/LinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs
index c085507365..7740141002 100644
--- a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs
+++ b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs
@@ -28,6 +28,8 @@ public class LinkBuilder : ILinkBuilder
private static readonly string GetRelationshipControllerActionName =
NoAsyncSuffix(nameof(BaseJsonApiController, int>.GetRelationshipAsync));
+ private static readonly UriNormalizer UriNormalizer = new();
+
private readonly IJsonApiOptions _options;
private readonly IJsonApiRequest _request;
private readonly IPaginationContext _paginationContext;
@@ -35,6 +37,7 @@ public class LinkBuilder : ILinkBuilder
private readonly LinkGenerator _linkGenerator;
private readonly IControllerResourceMapping _controllerResourceMapping;
private readonly IPaginationParser _paginationParser;
+ private readonly IDocumentDescriptionLinkProvider _documentDescriptionLinkProvider;
private HttpContext HttpContext
{
@@ -50,7 +53,8 @@ private HttpContext HttpContext
}
public LinkBuilder(IJsonApiOptions options, IJsonApiRequest request, IPaginationContext paginationContext, IHttpContextAccessor httpContextAccessor,
- LinkGenerator linkGenerator, IControllerResourceMapping controllerResourceMapping, IPaginationParser paginationParser)
+ LinkGenerator linkGenerator, IControllerResourceMapping controllerResourceMapping, IPaginationParser paginationParser,
+ IDocumentDescriptionLinkProvider documentDescriptionLinkProvider)
{
ArgumentGuard.NotNull(options);
ArgumentGuard.NotNull(request);
@@ -58,6 +62,7 @@ public LinkBuilder(IJsonApiOptions options, IJsonApiRequest request, IPagination
ArgumentGuard.NotNull(linkGenerator);
ArgumentGuard.NotNull(controllerResourceMapping);
ArgumentGuard.NotNull(paginationParser);
+ ArgumentGuard.NotNull(documentDescriptionLinkProvider);
_options = options;
_request = request;
@@ -66,6 +71,7 @@ public LinkBuilder(IJsonApiOptions options, IJsonApiRequest request, IPagination
_linkGenerator = linkGenerator;
_controllerResourceMapping = controllerResourceMapping;
_paginationParser = paginationParser;
+ _documentDescriptionLinkProvider = documentDescriptionLinkProvider;
}
private static string NoAsyncSuffix(string actionName)
@@ -94,6 +100,14 @@ private static string NoAsyncSuffix(string actionName)
SetPaginationInTopLevelLinks(resourceType!, links);
}
+ 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;
}
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/UriNormalizer.cs b/src/JsonApiDotNetCore/Serialization/Response/UriNormalizer.cs
new file mode 100644
index 0000000000..5b2517f4b0
--- /dev/null
+++ b/src/JsonApiDotNetCore/Serialization/Response/UriNormalizer.cs
@@ -0,0 +1,80 @@
+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);
+
+ if (!string.Equals(leftUserInfo, rightUserInfo))
+ {
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs
index 3221215461..74e5aaa25b 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs
@@ -58,6 +58,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Links.Last.Should().BeNull();
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().BeNull();
+ responseDocument.Links.DescribedBy.Should().BeNull();
responseDocument.Data.SingleValue.ShouldNotBeNull();
responseDocument.Data.SingleValue.Links.ShouldNotBeNull();
@@ -101,6 +102,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Links.Last.Should().Be(responseDocument.Links.Self);
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().BeNull();
+ responseDocument.Links.DescribedBy.Should().BeNull();
responseDocument.Data.ManyValue.ShouldHaveCount(1);
@@ -167,6 +169,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Links.Last.Should().BeNull();
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().BeNull();
+ responseDocument.Links.DescribedBy.Should().BeNull();
string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{photo.Album.StringId}";
@@ -211,6 +214,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Links.Last.Should().Be(responseDocument.Links.Self);
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().BeNull();
+ responseDocument.Links.DescribedBy.Should().BeNull();
responseDocument.Data.ManyValue.ShouldHaveCount(1);
@@ -259,6 +263,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Links.Last.Should().BeNull();
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().BeNull();
+ responseDocument.Links.DescribedBy.Should().BeNull();
responseDocument.Data.SingleValue.ShouldNotBeNull();
responseDocument.Data.SingleValue.Links.Should().BeNull();
@@ -293,6 +298,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Links.Last.Should().Be(responseDocument.Links.Self);
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().BeNull();
+ responseDocument.Links.DescribedBy.Should().BeNull();
responseDocument.Data.ManyValue.ShouldHaveCount(1);
responseDocument.Data.ManyValue[0].Links.Should().BeNull();
@@ -354,6 +360,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Links.Last.Should().BeNull();
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().BeNull();
+ responseDocument.Links.DescribedBy.Should().BeNull();
responseDocument.Data.SingleValue.ShouldNotBeNull();
responseDocument.Data.SingleValue.Links.ShouldNotBeNull();
@@ -437,6 +444,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Links.Last.Should().BeNull();
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().BeNull();
+ responseDocument.Links.DescribedBy.Should().BeNull();
string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}";
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs
index b6060e3d7c..6ba9636128 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs
@@ -58,6 +58,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Links.Last.Should().BeNull();
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().BeNull();
+ responseDocument.Links.DescribedBy.Should().BeNull();
responseDocument.Data.SingleValue.ShouldNotBeNull();
responseDocument.Data.SingleValue.Links.ShouldNotBeNull();
@@ -101,6 +102,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Links.Last.Should().Be(responseDocument.Links.Self);
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().BeNull();
+ responseDocument.Links.DescribedBy.Should().BeNull();
responseDocument.Data.ManyValue.ShouldHaveCount(1);
@@ -167,6 +169,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Links.Last.Should().BeNull();
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().BeNull();
+ responseDocument.Links.DescribedBy.Should().BeNull();
string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{photo.Album.StringId}";
@@ -211,6 +214,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Links.Last.Should().Be(responseDocument.Links.Self);
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().BeNull();
+ responseDocument.Links.DescribedBy.Should().BeNull();
responseDocument.Data.ManyValue.ShouldHaveCount(1);
@@ -259,6 +263,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Links.Last.Should().BeNull();
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().BeNull();
+ responseDocument.Links.DescribedBy.Should().BeNull();
responseDocument.Data.SingleValue.ShouldNotBeNull();
responseDocument.Data.SingleValue.Links.Should().BeNull();
@@ -293,6 +298,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Links.Last.Should().Be(responseDocument.Links.Self);
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().BeNull();
+ responseDocument.Links.DescribedBy.Should().BeNull();
responseDocument.Data.ManyValue.ShouldHaveCount(1);
responseDocument.Data.ManyValue[0].Links.Should().BeNull();
@@ -354,6 +360,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Links.Last.Should().BeNull();
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().BeNull();
+ responseDocument.Links.DescribedBy.Should().BeNull();
responseDocument.Data.SingleValue.ShouldNotBeNull();
responseDocument.Data.SingleValue.Links.ShouldNotBeNull();
@@ -437,6 +444,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Links.Last.Should().BeNull();
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().BeNull();
+ responseDocument.Links.DescribedBy.Should().BeNull();
string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}";
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/DocumentDescriptionLinkTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/DocumentDescriptionLinkTests.cs
new file mode 100644
index 0000000000..a2183247e5
--- /dev/null
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/DocumentDescriptionLinkTests.cs
@@ -0,0 +1,100 @@
+using System.Net;
+using FluentAssertions;
+using JsonApiDotNetCore.Configuration;
+using JsonApiDotNetCore.Serialization.Objects;
+using JsonApiDotNetCore.Serialization.Response;
+using Microsoft.Extensions.DependencyInjection;
+using TestBuildingBlocks;
+using Xunit;
+
+namespace JsonApiDotNetCoreTests.IntegrationTests.Links;
+
+public sealed class DocumentDescriptionLinkTests : IClassFixture, LinksDbContext>>
+{
+ private readonly IntegrationTestContext, LinksDbContext> _testContext;
+
+ public DocumentDescriptionLinkTests(IntegrationTestContext, LinksDbContext> testContext)
+ {
+ _testContext = testContext;
+
+ testContext.UseController();
+
+ testContext.ConfigureServices(services => services.AddSingleton());
+ }
+
+ [Fact]
+ public async Task Get_primary_resource_by_ID_converts_relative_documentation_link_to_absolute()
+ {
+ // Arrange
+ var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService();
+ options.UseRelativeLinks = false;
+
+ var provider = (TestDocumentDescriptionLinkProvider)_testContext.Factory.Services.GetRequiredService();
+ provider.Link = "description/json-schema?version=v1.0";
+
+ string route = $"/photos/{Unknown.StringId.For()}";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
+
+ // Assert
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound);
+
+ responseDocument.Links.ShouldNotBeNull();
+ responseDocument.Links.DescribedBy.Should().Be("http://localhost/description/json-schema?version=v1.0");
+ }
+
+ [Fact]
+ public async Task Get_primary_resource_by_ID_converts_absolute_documentation_link_to_relative()
+ {
+ // Arrange
+ var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService();
+ options.UseRelativeLinks = true;
+
+ var provider = (TestDocumentDescriptionLinkProvider)_testContext.Factory.Services.GetRequiredService();
+ provider.Link = "http://localhost:80/description/json-schema?version=v1.0";
+
+ string route = $"/photos/{Unknown.StringId.For()}";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
+
+ // Assert
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound);
+
+ responseDocument.Links.ShouldNotBeNull();
+ responseDocument.Links.DescribedBy.Should().Be("description/json-schema?version=v1.0");
+ }
+
+ [Fact]
+ public async Task Get_primary_resource_by_ID_cannot_convert_absolute_documentation_link_to_relative()
+ {
+ // Arrange
+ var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService();
+ options.UseRelativeLinks = true;
+
+ var provider = (TestDocumentDescriptionLinkProvider)_testContext.Factory.Services.GetRequiredService();
+ provider.Link = "https://docs.api.com/description/json-schema?version=v1.0";
+
+ string route = $"/photos/{Unknown.StringId.For()}";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route);
+
+ // Assert
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound);
+
+ responseDocument.Links.ShouldNotBeNull();
+ responseDocument.Links.DescribedBy.Should().Be("https://docs.api.com/description/json-schema?version=v1.0");
+ }
+
+ private sealed class TestDocumentDescriptionLinkProvider : IDocumentDescriptionLinkProvider
+ {
+ public string? Link { get; set; }
+
+ public string? GetUrl()
+ {
+ return Link;
+ }
+ }
+}
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs
index 79a2b8408a..604f9c3e90 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs
@@ -58,6 +58,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Links.Last.Should().BeNull();
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().BeNull();
+ responseDocument.Links.DescribedBy.Should().BeNull();
responseDocument.Data.SingleValue.ShouldNotBeNull();
responseDocument.Data.SingleValue.Links.ShouldNotBeNull();
@@ -101,6 +102,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Links.Last.Should().Be(responseDocument.Links.Self);
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().BeNull();
+ responseDocument.Links.DescribedBy.Should().BeNull();
responseDocument.Data.ManyValue.ShouldHaveCount(1);
@@ -167,6 +169,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Links.Last.Should().BeNull();
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().BeNull();
+ responseDocument.Links.DescribedBy.Should().BeNull();
string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{photo.Album.StringId}";
@@ -211,6 +214,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Links.Last.Should().Be(responseDocument.Links.Self);
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().BeNull();
+ responseDocument.Links.DescribedBy.Should().BeNull();
responseDocument.Data.ManyValue.ShouldHaveCount(1);
@@ -259,6 +263,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Links.Last.Should().BeNull();
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().BeNull();
+ responseDocument.Links.DescribedBy.Should().BeNull();
responseDocument.Data.SingleValue.ShouldNotBeNull();
responseDocument.Data.SingleValue.Links.Should().BeNull();
@@ -293,6 +298,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Links.Last.Should().Be(responseDocument.Links.Self);
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().BeNull();
+ responseDocument.Links.DescribedBy.Should().BeNull();
responseDocument.Data.ManyValue.ShouldHaveCount(1);
responseDocument.Data.ManyValue[0].Links.Should().BeNull();
@@ -354,6 +360,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Links.Last.Should().BeNull();
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().BeNull();
+ responseDocument.Links.DescribedBy.Should().BeNull();
responseDocument.Data.SingleValue.ShouldNotBeNull();
responseDocument.Data.SingleValue.Links.ShouldNotBeNull();
@@ -437,6 +444,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Links.Last.Should().BeNull();
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().BeNull();
+ responseDocument.Links.DescribedBy.Should().BeNull();
string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}";
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs
index 7e7e1d8f7a..6ce1effd7c 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs
@@ -58,6 +58,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Links.Last.Should().BeNull();
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().BeNull();
+ responseDocument.Links.DescribedBy.Should().BeNull();
responseDocument.Data.SingleValue.ShouldNotBeNull();
responseDocument.Data.SingleValue.Links.ShouldNotBeNull();
@@ -101,6 +102,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Links.Last.Should().Be(responseDocument.Links.Self);
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().BeNull();
+ responseDocument.Links.DescribedBy.Should().BeNull();
responseDocument.Data.ManyValue.ShouldHaveCount(1);
@@ -167,6 +169,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Links.Last.Should().BeNull();
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().BeNull();
+ responseDocument.Links.DescribedBy.Should().BeNull();
string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{photo.Album.StringId}";
@@ -211,6 +214,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Links.Last.Should().Be(responseDocument.Links.Self);
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().BeNull();
+ responseDocument.Links.DescribedBy.Should().BeNull();
responseDocument.Data.ManyValue.ShouldHaveCount(1);
@@ -259,6 +263,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Links.Last.Should().BeNull();
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().BeNull();
+ responseDocument.Links.DescribedBy.Should().BeNull();
responseDocument.Data.SingleValue.ShouldNotBeNull();
responseDocument.Data.SingleValue.Links.Should().BeNull();
@@ -293,6 +298,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Links.Last.Should().Be(responseDocument.Links.Self);
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().BeNull();
+ responseDocument.Links.DescribedBy.Should().BeNull();
responseDocument.Data.ManyValue.ShouldHaveCount(1);
responseDocument.Data.ManyValue[0].Links.Should().BeNull();
@@ -354,6 +360,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Links.Last.Should().BeNull();
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().BeNull();
+ responseDocument.Links.DescribedBy.Should().BeNull();
responseDocument.Data.SingleValue.ShouldNotBeNull();
responseDocument.Data.SingleValue.Links.ShouldNotBeNull();
@@ -437,6 +444,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
responseDocument.Links.Last.Should().BeNull();
responseDocument.Links.Prev.Should().BeNull();
responseDocument.Links.Next.Should().BeNull();
+ responseDocument.Links.DescribedBy.Should().BeNull();
string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}";
diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringTests.cs
index 0aa955a219..4f6ee95ad2 100644
--- a/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringTests.cs
+++ b/test/JsonApiDotNetCoreTests/IntegrationTests/QueryStrings/QueryStringTests.cs
@@ -63,6 +63,24 @@ public async Task Can_use_unknown_query_string_parameter()
httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
}
+ [Theory]
+ [InlineData("")]
+ [InlineData("bar")]
+ public async Task Can_use_empty_query_string_parameter_name(string parameterValue)
+ {
+ // Arrange
+ var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService();
+ options.AllowUnknownQueryStringParameters = false;
+
+ string route = $"calendars?={parameterValue}";
+
+ // Act
+ (HttpResponseMessage httpResponse, Document _) = await _testContext.ExecuteGetAsync(route);
+
+ // Assert
+ httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK);
+ }
+
[Theory]
[InlineData("filter")]
[InlineData("sort")]
diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs
index 392d61709a..94b9cb9386 100644
--- a/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs
+++ b/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs
@@ -88,7 +88,10 @@ public void Applies_cascading_settings_for_top_level_links(LinkTypes linksInReso
var linkGenerator = new FakeLinkGenerator();
var controllerResourceMapping = new FakeControllerResourceMapping();
var paginationParser = new PaginationParser();
- var linkBuilder = new LinkBuilder(options, request, paginationContext, httpContextAccessor, linkGenerator, controllerResourceMapping, paginationParser);
+ var documentDescriptionLinkProvider = new NoDocumentDescriptionLinkProvider();
+
+ var linkBuilder = new LinkBuilder(options, request, paginationContext, httpContextAccessor, linkGenerator, controllerResourceMapping, paginationParser,
+ documentDescriptionLinkProvider);
// Act
TopLevelLinks? topLevelLinks = linkBuilder.GetTopLevelLinks();
@@ -171,7 +174,10 @@ public void Applies_cascading_settings_for_resource_links(LinkTypes linksInResou
var linkGenerator = new FakeLinkGenerator();
var controllerResourceMapping = new FakeControllerResourceMapping();
var paginationParser = new PaginationParser();
- var linkBuilder = new LinkBuilder(options, request, paginationContext, httpContextAccessor, linkGenerator, controllerResourceMapping, paginationParser);
+ var documentDescriptionLinkProvider = new NoDocumentDescriptionLinkProvider();
+
+ var linkBuilder = new LinkBuilder(options, request, paginationContext, httpContextAccessor, linkGenerator, controllerResourceMapping, paginationParser,
+ documentDescriptionLinkProvider);
// Act
ResourceLinks? resourceLinks = linkBuilder.GetResourceLinks(exampleResourceType, new ExampleResource());
@@ -332,7 +338,10 @@ public void Applies_cascading_settings_for_relationship_links(LinkTypes linksInR
var linkGenerator = new FakeLinkGenerator();
var controllerResourceMapping = new FakeControllerResourceMapping();
var paginationParser = new PaginationParser();
- var linkBuilder = new LinkBuilder(options, request, paginationContext, httpContextAccessor, linkGenerator, controllerResourceMapping, paginationParser);
+ var documentDescriptionLinkProvider = new NoDocumentDescriptionLinkProvider();
+
+ var linkBuilder = new LinkBuilder(options, request, paginationContext, httpContextAccessor, linkGenerator, controllerResourceMapping, paginationParser,
+ documentDescriptionLinkProvider);
var relationship = new HasOneAttribute
{
diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Links/UriNormalizerTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Links/UriNormalizerTests.cs
new file mode 100644
index 0000000000..545866c6ff
--- /dev/null
+++ b/test/JsonApiDotNetCoreTests/UnitTests/Links/UriNormalizerTests.cs
@@ -0,0 +1,78 @@
+using FluentAssertions;
+using JsonApiDotNetCore.Serialization.Response;
+using Xunit;
+
+namespace JsonApiDotNetCoreTests.UnitTests.Links;
+
+public sealed class UriNormalizerTests
+{
+ [Theory]
+ [InlineData("some/path", "http://localhost")]
+ [InlineData("some?version=1", "http://localhost")]
+ public void Keeps_relative_URL_relative(string sourceUrl, string requestUrl)
+ {
+ // Arrange
+ var normalizer = new UriNormalizer();
+
+ // Act
+ string result = normalizer.Normalize(sourceUrl, true, new Uri(requestUrl));
+
+ // Assert
+ result.Should().Be(sourceUrl);
+ }
+
+ [Theory]
+ [InlineData("some/path", "http://localhost", "http://localhost/some/path")]
+ [InlineData("some/path", "https://api-server.com", "https://api-server.com/some/path")]
+ [InlineData("some/path", "https://user:pass@api-server.com:9999", "https://user:pass@api-server.com:9999/some/path")]
+ [InlineData("some/path", "http://localhost/api/articles?debug=true#anchor", "http://localhost/some/path")]
+ [InlineData("some?version=1", "http://localhost/api/articles/1?debug=true#anchor", "http://localhost/some?version=1")]
+ public void Makes_relative_URL_absolute(string sourceUrl, string requestUrl, string expected)
+ {
+ // Arrange
+ var normalizer = new UriNormalizer();
+
+ // Act
+ string result = normalizer.Normalize(sourceUrl, false, new Uri(requestUrl));
+
+ // Assert
+ result.Should().Be(expected);
+ }
+
+ [Theory]
+ [InlineData("http://localhost/some/path", "http://api-server.com")]
+ [InlineData("http://localhost/some/path", "https://localhost")]
+ [InlineData("http://localhost:8080/some/path", "http://localhost")]
+ [InlineData("http://user:pass@localhost/some/path?version=1", "http://localhost")]
+ [InlineData("http://user:pass@localhost/some/path?version=1", "http://USER:PASS@localhost")]
+ public void Keeps_absolute_URL_absolute(string sourceUrl, string requestUrl)
+ {
+ // Arrange
+ var normalizer = new UriNormalizer();
+
+ // Act
+ string result = normalizer.Normalize(sourceUrl, true, new Uri(requestUrl));
+
+ // Assert
+ result.Should().Be(sourceUrl);
+ }
+
+ [Theory]
+ [InlineData("http://localhost/some/path", "http://localhost/api/articles/1", "some/path")]
+ [InlineData("http://api-server.com/some/path", "http://api-server.com/api/articles/1", "some/path")]
+ [InlineData("https://localhost/some/path", "https://localhost/api/articles/1", "some/path")]
+ [InlineData("https://localhost:443/some/path", "https://localhost/api/articles/1", "some/path")]
+ [InlineData("https://localhost/some/path", "https://localhost:443/api/articles/1", "some/path")]
+ [InlineData("HTTPS://LOCALHOST/some/path", "https://localhost:443/api/articles/1", "some/path")]
+ public void Makes_absolute_URL_relative(string sourceUrl, string requestUrl, string expected)
+ {
+ // Arrange
+ var normalizer = new UriNormalizer();
+
+ // Act
+ string result = normalizer.Normalize(sourceUrl, true, new Uri(requestUrl));
+
+ // Assert
+ result.Should().Be(expected);
+ }
+}
diff --git a/test/TestBuildingBlocks/FakerContainer.cs b/test/TestBuildingBlocks/FakerContainer.cs
index c7104852d1..7fae979650 100644
--- a/test/TestBuildingBlocks/FakerContainer.cs
+++ b/test/TestBuildingBlocks/FakerContainer.cs
@@ -34,7 +34,7 @@ private static MethodBase GetTestMethod()
if (testMethod == null)
{
// If called after the first await statement, the test method is no longer on the stack,
- // but has been replaced with the compiler-generated async/wait state machine.
+ // but has been replaced with the compiler-generated async/await state machine.
throw new InvalidOperationException("Fakers can only be used from within (the start of) a test method.");
}