From 28e89174f881da43d4a984c997c5e56bfa867028 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Jul 2024 15:25:17 +0000 Subject: [PATCH 1/5] Bump regitlint from 6.3.12 to 6.3.13 (#1590) --- .config/dotnet-tools.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index b1246c5e2f..8fe8ffcf0d 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -9,7 +9,7 @@ ] }, "regitlint": { - "version": "6.3.12", + "version": "6.3.13", "commands": [ "regitlint" ] From 8a9b85c78141826dedec4ef989af728ef7509abc Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 13 Jul 2024 18:32:11 +0200 Subject: [PATCH 2/5] Improve test coverage for client ID generation modes --- .../AssignIdToTextLanguageDefinition.cs | 20 ++ ...reateResourceWithClientGeneratedIdTests.cs | 181 ++++++++++++++++-- .../Creating/AssignIdToRgbColorDefinition.cs | 29 +++ ...reateResourceWithClientGeneratedIdTests.cs | 142 ++++++++++++-- ...eateResourceWithToManyRelationshipTests.cs | 8 +- .../IntegrationTests/ReadWrite/RgbColor.cs | 7 +- .../IntegrationTests/ZeroKeys/Player.cs | 2 +- 7 files changed, 345 insertions(+), 44 deletions(-) create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AssignIdToTextLanguageDefinition.cs create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/AssignIdToRgbColorDefinition.cs diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AssignIdToTextLanguageDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AssignIdToTextLanguageDefinition.cs new file mode 100644 index 0000000000..730f255af9 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AssignIdToTextLanguageDefinition.cs @@ -0,0 +1,20 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; + +namespace JsonApiDotNetCoreTests.IntegrationTests.AtomicOperations.Creating; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +public sealed class AssignIdToTextLanguageDefinition(IResourceGraph resourceGraph, ResourceDefinitionHitCounter hitCounter, OperationsDbContext dbContext) + : ImplicitlyChangingTextLanguageDefinition(resourceGraph, hitCounter, dbContext) +{ + public override Task OnWritingAsync(TextLanguage resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (writeOperation == WriteOperationKind.CreateResource && resource.Id == Guid.Empty) + { + resource.Id = Guid.NewGuid(); + } + + return Task.CompletedTask; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs index e69cfb7d1a..2d18a44a52 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs @@ -25,20 +25,22 @@ public AtomicCreateResourceWithClientGeneratedIdTests(IntegrationTestContext { - services.AddResourceDefinition(); + services.AddResourceDefinition(); services.AddSingleton(); services.AddSingleton(); }); - - var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); - options.ClientIdGeneration = ClientIdGenerationMode.Required; } - [Fact] - public async Task Can_create_resource_with_client_generated_guid_ID_having_side_effects() + [Theory] + [InlineData(ClientIdGenerationMode.Allowed)] + [InlineData(ClientIdGenerationMode.Required)] + public async Task Can_create_resource_with_client_generated_guid_ID_having_side_effects(ClientIdGenerationMode mode) { // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.ClientIdGeneration = mode; + TextLanguage newLanguage = _fakers.TextLanguage.Generate(); newLanguage.Id = Guid.NewGuid(); @@ -90,10 +92,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - [Fact] - public async Task Can_create_resource_with_client_generated_string_ID_having_no_side_effects() + [Theory] + [InlineData(ClientIdGenerationMode.Allowed)] + [InlineData(ClientIdGenerationMode.Required)] + public async Task Can_create_resource_with_client_generated_guid_ID_having_no_side_effects(ClientIdGenerationMode mode) { // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.ClientIdGeneration = mode; + MusicTrack newTrack = _fakers.MusicTrack.Generate(); newTrack.Id = Guid.NewGuid(); @@ -138,10 +145,72 @@ await _testContext.RunOnDatabaseAsync(async dbContext => }); } - [Fact] - public async Task Cannot_create_resource_for_missing_client_generated_ID() + [Theory] + [InlineData(ClientIdGenerationMode.Allowed)] + public async Task Can_create_resource_for_missing_client_generated_ID_having_side_effects(ClientIdGenerationMode mode) { // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.ClientIdGeneration = mode; + + string? newIsoCode = _fakers.TextLanguage.Generate().IsoCode; + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "textLanguages", + attributes = new + { + isoCode = newIsoCode + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + string isoCode = $"{newIsoCode}{ImplicitlyChangingTextLanguageDefinition.Suffix}"; + + responseDocument.Results.ShouldHaveCount(1); + + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("textLanguages"); + resource.Attributes.ShouldContainKey("isoCode").With(value => value.Should().Be(isoCode)); + resource.Relationships.ShouldNotBeEmpty(); + }); + + Guid newLanguageId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + TextLanguage languageInDatabase = await dbContext.TextLanguages.FirstWithIdAsync(newLanguageId); + + languageInDatabase.IsoCode.Should().Be(isoCode); + }); + } + + [Theory] + [InlineData(ClientIdGenerationMode.Required)] + public async Task Cannot_create_resource_for_missing_client_generated_ID(ClientIdGenerationMode mode) + { + // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.ClientIdGeneration = mode; + string? newIsoCode = _fakers.TextLanguage.Generate().IsoCode; var requestBody = new @@ -182,10 +251,15 @@ public async Task Cannot_create_resource_for_missing_client_generated_ID() error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } - [Fact] - public async Task Cannot_create_resource_for_existing_client_generated_ID() + [Theory] + [InlineData(ClientIdGenerationMode.Allowed)] + [InlineData(ClientIdGenerationMode.Required)] + public async Task Cannot_create_resource_for_existing_client_generated_ID(ClientIdGenerationMode mode) { // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.ClientIdGeneration = mode; + TextLanguage existingLanguage = _fakers.TextLanguage.Generate(); existingLanguage.Id = Guid.NewGuid(); @@ -237,10 +311,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => error.Meta.Should().NotContainKey("requestBody"); } - [Fact] - public async Task Cannot_create_resource_for_incompatible_ID() + [Theory] + [InlineData(ClientIdGenerationMode.Allowed)] + [InlineData(ClientIdGenerationMode.Required)] + public async Task Cannot_create_resource_for_incompatible_ID(ClientIdGenerationMode mode) { // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.ClientIdGeneration = mode; + string guid = Unknown.StringId.Guid; var requestBody = new @@ -281,10 +360,71 @@ public async Task Cannot_create_resource_for_incompatible_ID() error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } - [Fact] - public async Task Cannot_create_resource_with_local_ID() + [Theory] + [InlineData(ClientIdGenerationMode.Allowed)] + public async Task Can_create_resource_with_local_ID(ClientIdGenerationMode mode) + { + // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.ClientIdGeneration = mode; + + string newTitle = _fakers.MusicTrack.Generate().Title; + + var requestBody = new + { + atomic__operations = new[] + { + new + { + op = "add", + data = new + { + type = "musicTracks", + lid = "new-server-id", + attributes = new + { + title = newTitle + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.ShouldHaveCount(1); + + responseDocument.Results[0].Data.SingleValue.ShouldNotBeNull().With(resource => + { + resource.Type.Should().Be("musicTracks"); + resource.Attributes.ShouldContainKey("title").With(value => value.Should().Be(newTitle)); + resource.Relationships.Should().BeNull(); + }); + + Guid newTrackId = Guid.Parse(responseDocument.Results[0].Data.SingleValue!.Id.ShouldNotBeNull()); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + MusicTrack languageInDatabase = await dbContext.MusicTracks.FirstWithIdAsync(newTrackId); + + languageInDatabase.Title.Should().Be(newTitle); + }); + } + + [Theory] + [InlineData(ClientIdGenerationMode.Required)] + public async Task Cannot_create_resource_with_local_ID(ClientIdGenerationMode mode) { // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.ClientIdGeneration = mode; + var requestBody = new { atomic__operations = new[] @@ -320,10 +460,15 @@ public async Task Cannot_create_resource_with_local_ID() error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } - [Fact] - public async Task Cannot_create_resource_for_ID_and_local_ID() + [Theory] + [InlineData(ClientIdGenerationMode.Allowed)] + [InlineData(ClientIdGenerationMode.Required)] + public async Task Cannot_create_resource_for_ID_and_local_ID(ClientIdGenerationMode mode) { // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.ClientIdGeneration = mode; + var requestBody = new { atomic__operations = new[] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/AssignIdToRgbColorDefinition.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/AssignIdToRgbColorDefinition.cs new file mode 100644 index 0000000000..c1e1c8a576 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/AssignIdToRgbColorDefinition.cs @@ -0,0 +1,29 @@ +using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; + +namespace JsonApiDotNetCoreTests.IntegrationTests.ReadWrite.Creating; + +[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] +internal sealed class AssignIdToRgbColorDefinition(IResourceGraph resourceGraph) : JsonApiResourceDefinition(resourceGraph) +{ + internal const string DefaultId = "0x000000"; + internal const string DefaultName = "Black"; + + public override Task OnWritingAsync(RgbColor resource, WriteOperationKind writeOperation, CancellationToken cancellationToken) + { + if (writeOperation == WriteOperationKind.CreateResource && resource.Id == null) + { + SetDefaultColor(resource); + } + + return Task.CompletedTask; + } + + private static void SetDefaultColor(RgbColor resource) + { + resource.Id = DefaultId; + resource.DisplayName = DefaultName; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs index 92c88d72a4..0b2bb26171 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs @@ -22,16 +22,22 @@ public CreateResourceWithClientGeneratedIdTests(IntegrationTestContext(); testContext.UseController(); - testContext.ConfigureServices(services => services.AddResourceDefinition()); - - var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); - options.ClientIdGeneration = ClientIdGenerationMode.Required; + testContext.ConfigureServices(services => + { + services.AddResourceDefinition(); + services.AddResourceDefinition(); + }); } - [Fact] - public async Task Can_create_resource_with_client_generated_guid_ID_having_side_effects() + [Theory] + [InlineData(ClientIdGenerationMode.Allowed)] + [InlineData(ClientIdGenerationMode.Required)] + public async Task Can_create_resource_with_client_generated_guid_ID_having_side_effects(ClientIdGenerationMode mode) { // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.ClientIdGeneration = mode; + WorkItemGroup newGroup = _fakers.WorkItemGroup.Generate(); newGroup.Id = Guid.NewGuid(); @@ -76,10 +82,15 @@ await _testContext.RunOnDatabaseAsync(async dbContext => property.PropertyType.Should().Be(typeof(Guid)); } - [Fact] - public async Task Can_create_resource_with_client_generated_guid_ID_having_side_effects_with_fieldset() + [Theory] + [InlineData(ClientIdGenerationMode.Allowed)] + [InlineData(ClientIdGenerationMode.Required)] + public async Task Can_create_resource_with_client_generated_guid_ID_having_side_effects_with_fieldset(ClientIdGenerationMode mode) { // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.ClientIdGeneration = mode; + WorkItemGroup newGroup = _fakers.WorkItemGroup.Generate(); newGroup.Id = Guid.NewGuid(); @@ -125,12 +136,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => property.PropertyType.Should().Be(typeof(Guid)); } - [Fact] - public async Task Can_create_resource_with_client_generated_string_ID_having_no_side_effects() + [Theory] + [InlineData(ClientIdGenerationMode.Allowed)] + [InlineData(ClientIdGenerationMode.Required)] + public async Task Can_create_resource_with_client_generated_string_ID_having_no_side_effects(ClientIdGenerationMode mode) { // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.ClientIdGeneration = mode; + RgbColor newColor = _fakers.RgbColor.Generate(); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + }); + var requestBody = new { data = new @@ -166,12 +187,22 @@ await _testContext.RunOnDatabaseAsync(async dbContext => property.PropertyType.Should().Be(typeof(string)); } - [Fact] - public async Task Can_create_resource_with_client_generated_string_ID_having_no_side_effects_with_fieldset() + [Theory] + [InlineData(ClientIdGenerationMode.Allowed)] + [InlineData(ClientIdGenerationMode.Required)] + public async Task Can_create_resource_with_client_generated_string_ID_having_no_side_effects_with_fieldset(ClientIdGenerationMode mode) { // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.ClientIdGeneration = mode; + RgbColor newColor = _fakers.RgbColor.Generate(); + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + }); + var requestBody = new { data = new @@ -207,12 +238,77 @@ await _testContext.RunOnDatabaseAsync(async dbContext => property.PropertyType.Should().Be(typeof(string)); } - [Fact] - public async Task Cannot_create_resource_for_missing_client_generated_ID() + [Theory] + [InlineData(ClientIdGenerationMode.Allowed)] + public async Task Can_create_resource_for_missing_client_generated_ID_having_side_effects(ClientIdGenerationMode mode) { // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.ClientIdGeneration = mode; + string newDisplayName = _fakers.RgbColor.Generate().DisplayName; + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + }); + + var requestBody = new + { + data = new + { + type = "rgbColors", + attributes = new + { + displayName = newDisplayName + } + } + }; + + const string route = "/rgbColors"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.Created); + + const string defaultId = AssignIdToRgbColorDefinition.DefaultId; + const string defaultName = AssignIdToRgbColorDefinition.DefaultName; + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Type.Should().Be("rgbColors"); + responseDocument.Data.SingleValue.Id.Should().Be(defaultId); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("displayName").With(value => value.Should().Be(defaultName)); + responseDocument.Data.SingleValue.Relationships.ShouldNotBeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + RgbColor colorInDatabase = await dbContext.RgbColors.FirstWithIdAsync((string?)defaultId); + + colorInDatabase.DisplayName.Should().Be(defaultName); + }); + + PropertyInfo? property = typeof(RgbColor).GetProperty(nameof(Identifiable.Id)); + property.ShouldNotBeNull(); + property.PropertyType.Should().Be(typeof(string)); + } + + [Theory] + [InlineData(ClientIdGenerationMode.Required)] + public async Task Cannot_create_resource_for_missing_client_generated_ID(ClientIdGenerationMode mode) + { + // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.ClientIdGeneration = mode; + + string newDisplayName = _fakers.RgbColor.Generate().DisplayName; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + }); + var requestBody = new { data = new @@ -244,17 +340,23 @@ public async Task Cannot_create_resource_for_missing_client_generated_ID() error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } - [Fact] - public async Task Cannot_create_resource_for_existing_client_generated_ID() + [Theory] + [InlineData(ClientIdGenerationMode.Allowed)] + [InlineData(ClientIdGenerationMode.Required)] + public async Task Cannot_create_resource_for_existing_client_generated_ID(ClientIdGenerationMode mode) { // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.ClientIdGeneration = mode; + RgbColor existingColor = _fakers.RgbColor.Generate(); - RgbColor colorToCreate = _fakers.RgbColor.Generate(); - colorToCreate.Id = existingColor.Id; + RgbColor newColor = _fakers.RgbColor.Generate(); + newColor.Id = existingColor.Id; await _testContext.RunOnDatabaseAsync(async dbContext => { + await dbContext.ClearTableAsync(); dbContext.RgbColors.Add(existingColor); await dbContext.SaveChangesAsync(); }); @@ -264,10 +366,10 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new { type = "rgbColors", - id = colorToCreate.StringId, + id = newColor.StringId, attributes = new { - displayName = colorToCreate.DisplayName + displayName = newColor.DisplayName } } }; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs index 0a6ed42c60..f06c2b7820 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithToManyRelationshipTests.cs @@ -232,7 +232,7 @@ public async Task Can_create_ManyToMany_relationship_with_include_and_fieldsets( { // Arrange List existingTags = _fakers.WorkTag.Generate(3); - WorkItem workItemToCreate = _fakers.WorkItem.Generate(); + WorkItem newWorkItem = _fakers.WorkItem.Generate(); await _testContext.RunOnDatabaseAsync(async dbContext => { @@ -247,8 +247,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => type = "workItems", attributes = new { - description = workItemToCreate.Description, - priority = workItemToCreate.Priority + description = newWorkItem.Description, + priority = newWorkItem.Priority }, relationships = new { @@ -287,7 +287,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Attributes.ShouldHaveCount(1); - responseDocument.Data.SingleValue.Attributes.ShouldContainKey("priority").With(value => value.Should().Be(workItemToCreate.Priority)); + responseDocument.Data.SingleValue.Attributes.ShouldContainKey("priority").With(value => value.Should().Be(newWorkItem.Priority)); responseDocument.Data.SingleValue.Relationships.ShouldHaveCount(1); responseDocument.Data.SingleValue.Relationships.ShouldContainKey("tags").With(value => diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/RgbColor.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/RgbColor.cs index 05af73148f..8eeabbee1d 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/RgbColor.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/RgbColor.cs @@ -6,8 +6,13 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ReadWrite; [UsedImplicitly(ImplicitUseTargetFlags.Members)] [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.ReadWrite")] -public sealed class RgbColor : Identifiable +public sealed class RgbColor : Identifiable { +#if NET6_0 + // Workaround for bug in .NET 6, see https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/1153. + public override string? Id { get; set; } +#endif + [Attr] public string DisplayName { get; set; } = null!; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/Player.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/Player.cs index cc38d5360b..b2e7cf2c58 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/Player.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/Player.cs @@ -6,7 +6,7 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ZeroKeys; [UsedImplicitly(ImplicitUseTargetFlags.Members)] [Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.ZeroKeys")] -public sealed class Player : Identifiable +public sealed class Player : Identifiable { [Attr] public string EmailAddress { get; set; } = null!; From db16af1492bea0b218f66f42225b25a86391b8be Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 13 Jul 2024 22:30:36 +0200 Subject: [PATCH 3/5] Produce error on invalid ID in request body --- .../Adapters/ResourceIdentityAdapter.cs | 20 ++ ...reateResourceWithClientGeneratedIdTests.cs | 226 ++++++++++++++++++ 2 files changed, 246 insertions(+) diff --git a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs index 5e25dba0f7..8019b5884e 100644 --- a/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs +++ b/src/JsonApiDotNetCore/Serialization/Request/Adapters/ResourceIdentityAdapter.cs @@ -116,6 +116,7 @@ private IIdentifiable CreateResource(ResourceIdentity identity, ResourceIdentity AssertHasNoId(identity, state); } + AssertNoBrokenId(identity, resourceType.IdentityClrType, state); AssertSameIdValue(identity, requirements.IdValue, state); AssertSameLidValue(identity, requirements.LidValue, state); @@ -177,6 +178,25 @@ private static void AssertHasNoId(ResourceIdentity identity, RequestAdapterState } } + 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) diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs index 0b2bb26171..f2e2b5e836 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ReadWrite/Creating/CreateResourceWithClientGeneratedIdTests.cs @@ -21,6 +21,7 @@ public CreateResourceWithClientGeneratedIdTests(IntegrationTestContext(); testContext.UseController(); + testContext.UseController(); testContext.ConfigureServices(services => { @@ -340,6 +341,231 @@ await _testContext.RunOnDatabaseAsync(async dbContext => error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); } + [Theory] + [InlineData(ClientIdGenerationMode.Allowed)] + [InlineData(ClientIdGenerationMode.Required)] + public async Task Cannot_create_resource_with_client_generated_zero_guid_ID(ClientIdGenerationMode mode) + { + // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.ClientIdGeneration = mode; + + WorkItemGroup newGroup = _fakers.WorkItemGroup.Generate(); + + var requestBody = new + { + data = new + { + type = "workItemGroups", + id = Guid.Empty.ToString(), + attributes = new + { + name = newGroup.Name + } + } + }; + + const string route = "/workItemGroups"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' element is invalid."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [Theory] + [InlineData(ClientIdGenerationMode.Allowed)] + [InlineData(ClientIdGenerationMode.Required)] + public async Task Cannot_create_resource_with_client_generated_empty_guid_ID(ClientIdGenerationMode mode) + { + // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.ClientIdGeneration = mode; + + WorkItemGroup newGroup = _fakers.WorkItemGroup.Generate(); + + var requestBody = new + { + data = new + { + type = "workItemGroups", + id = string.Empty, + attributes = new + { + name = newGroup.Name + } + } + }; + + const string route = "/workItemGroups"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' element is invalid."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [Theory] + [InlineData(ClientIdGenerationMode.Allowed)] + [InlineData(ClientIdGenerationMode.Required)] + public async Task Can_create_resource_with_client_generated_empty_string_ID(ClientIdGenerationMode mode) + { + // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.ClientIdGeneration = mode; + + RgbColor newColor = _fakers.RgbColor.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + }); + + var requestBody = new + { + data = new + { + type = "rgbColors", + id = string.Empty, + attributes = new + { + displayName = newColor.DisplayName + } + } + }; + + const string route = "/rgbColors?fields[rgbColors]=id"; + + // 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.FirstWithIdAsync((string?)string.Empty); + + colorInDatabase.DisplayName.Should().Be(newColor.DisplayName); + }); + + PropertyInfo? property = typeof(RgbColor).GetProperty(nameof(Identifiable.Id)); + property.ShouldNotBeNull(); + property.PropertyType.Should().Be(typeof(string)); + } + + [Theory] + [InlineData(ClientIdGenerationMode.Allowed)] + [InlineData(ClientIdGenerationMode.Required)] + public async Task Cannot_create_resource_with_client_generated_zero_long_ID(ClientIdGenerationMode mode) + { + // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.ClientIdGeneration = mode; + + UserAccount newAccount = _fakers.UserAccount.Generate(); + + var requestBody = new + { + data = new + { + type = "userAccounts", + id = "0", + attributes = new + { + firstName = newAccount.FirstName, + lastName = newAccount.LastName + } + } + }; + + const string route = "/userAccounts"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' element is invalid."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + + [Theory] + [InlineData(ClientIdGenerationMode.Allowed)] + [InlineData(ClientIdGenerationMode.Required)] + public async Task Cannot_create_resource_with_client_generated_empty_long_ID(ClientIdGenerationMode mode) + { + // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.ClientIdGeneration = mode; + + UserAccount newAccount = _fakers.UserAccount.Generate(); + + var requestBody = new + { + data = new + { + type = "userAccounts", + id = string.Empty, + attributes = new + { + firstName = newAccount.FirstName, + lastName = newAccount.LastName + } + } + }; + + const string route = "/userAccounts"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.UnprocessableEntity); + + responseDocument.Errors.ShouldHaveCount(1); + + ErrorObject error = responseDocument.Errors[0]; + error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + error.Title.Should().Be("Failed to deserialize request body: The 'id' element is invalid."); + error.Detail.Should().BeNull(); + error.Source.ShouldNotBeNull(); + error.Source.Pointer.Should().Be("/data"); + error.Meta.ShouldContainKey("requestBody").With(value => value.ShouldNotBeNull().ToString().ShouldNotBeEmpty()); + } + [Theory] [InlineData(ClientIdGenerationMode.Allowed)] [InlineData(ClientIdGenerationMode.Required)] From 9dc1c69db2c6a09d7b1dbe1cf55c371bff494033 Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sat, 13 Jul 2024 22:59:51 +0200 Subject: [PATCH 4/5] Optimize default value lookups (20% faster) | Method | Mean | Error | StdDev | Ratio | Allocated | Alloc Ratio | |------------------------ |---------:|---------:|---------:|------:|----------:|------------:| | ActivatorCreateInstance | 84.13 ms | 4.029 ms | 1.046 ms | 1.00 | 69 B | 1.00 | | LookupCached | 67.93 ms | 0.345 ms | 0.053 ms | 0.81 | 7 B | 0.10 | ```c# using System.Collections.Concurrent; using BenchmarkDotNet.Attributes; namespace Benchmarks; // ReSharper disable once ClassCanBeSealed.Global [MarkdownExporter] [SimpleJob(1, 5, 5)] [MemoryDiagnoser] public class DefaultValueBenchmarks { private const int IterationCount = 10_000_000; private static readonly ConcurrentDictionary Cache = new() { [typeof(int?)] = null, [typeof(Guid?)] = null }; [Benchmark(Baseline = true)] public void ActivatorCreateInstance() { for (int index = 0; index < IterationCount; index++) { _ = Activator.CreateInstance(typeof(int?)); _ = Activator.CreateInstance(typeof(Guid?)); } } [Benchmark] public void LookupCached() { for (int index = 0; index < IterationCount; index++) { _ = Cache.TryGetValue(typeof(int?), out _); _ = Cache.TryGetValue(typeof(Guid?), out _); } } } ``` --- .../Repositories/ResultSetMapper.cs | 17 +---------------- .../Resources/RuntimeTypeConverter.cs | 9 ++++++++- .../Resources/IdentifiableExtensions.cs | 13 ++++--------- 3 files changed, 13 insertions(+), 26 deletions(-) diff --git a/src/Examples/DapperExample/Repositories/ResultSetMapper.cs b/src/Examples/DapperExample/Repositories/ResultSetMapper.cs index 4352b6c552..4a90bd587a 100644 --- a/src/Examples/DapperExample/Repositories/ResultSetMapper.cs +++ b/src/Examples/DapperExample/Repositories/ResultSetMapper.cs @@ -18,9 +18,6 @@ internal sealed class ResultSetMapper // Note we don't do full bidirectional relationship fix-up; this just avoids duplicate instances. private readonly Dictionary> _resourceByTypeCache = []; - // Optimization to avoid unneeded calls to expensive Activator.CreateInstance() method, which is needed multiple times per row. - private readonly Dictionary _defaultValueByTypeCache = []; - // Used to determine where in the tree of included relationships a join object belongs to. private readonly Dictionary _includeElementToJoinObjectArrayIndexLookup = new(ReferenceEqualityComparer.Instance); @@ -114,22 +111,10 @@ public ResultSetMapper(IncludeExpression? include) private bool HasDefaultValue(object value) { - object? defaultValue = GetDefaultValueCached(value.GetType()); + object? defaultValue = RuntimeTypeConverter.GetDefaultValue(value.GetType()); return Equals(defaultValue, value); } - private object? GetDefaultValueCached(Type type) - { - if (_defaultValueByTypeCache.TryGetValue(type, out object? defaultValue)) - { - return defaultValue; - } - - defaultValue = RuntimeTypeConverter.GetDefaultValue(type); - _defaultValueByTypeCache[type] = defaultValue; - return defaultValue; - } - private void RecursiveSetRelationships(object leftResource, IEnumerable includeElements, object?[] joinObjects) { foreach (IncludeElementExpression includeElement in includeElements) diff --git a/src/JsonApiDotNetCore.Annotations/Resources/RuntimeTypeConverter.cs b/src/JsonApiDotNetCore.Annotations/Resources/RuntimeTypeConverter.cs index 14c35b8e26..a811dd0646 100644 --- a/src/JsonApiDotNetCore.Annotations/Resources/RuntimeTypeConverter.cs +++ b/src/JsonApiDotNetCore.Annotations/Resources/RuntimeTypeConverter.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Globalization; using JetBrains.Annotations; @@ -13,6 +14,8 @@ public static class RuntimeTypeConverter { private const string ParseQueryStringsUsingCurrentCultureSwitchName = "JsonApiDotNetCore.ParseQueryStringsUsingCurrentCulture"; + private static readonly ConcurrentDictionary DefaultTypeCache = new(); + /// /// Converts the specified value to the specified type. /// @@ -137,6 +140,8 @@ public static class RuntimeTypeConverter /// public static bool CanContainNull(Type type) { + ArgumentGuard.NotNull(type); + return !type.IsValueType || Nullable.GetUnderlyingType(type) != null; } @@ -148,6 +153,8 @@ public static bool CanContainNull(Type type) /// public static object? GetDefaultValue(Type type) { - return type.IsValueType ? Activator.CreateInstance(type) : null; + ArgumentGuard.NotNull(type); + + return type.IsValueType ? DefaultTypeCache.GetOrAdd(type, Activator.CreateInstance) : null; } } diff --git a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs index 34af2f36fe..91905e4df3 100644 --- a/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs +++ b/src/JsonApiDotNetCore/Resources/IdentifiableExtensions.cs @@ -18,17 +18,12 @@ public static object GetTypedId(this IIdentifiable identifiable) } object? propertyValue = property.GetValue(identifiable); + object? defaultValue = RuntimeTypeConverter.GetDefaultValue(property.PropertyType); - // PERF: We want to throw when 'Id' is unassigned without doing an expensive reflection call, unless this is likely the case. - if (identifiable.StringId == null) + if (Equals(propertyValue, defaultValue)) { - object? defaultValue = RuntimeTypeConverter.GetDefaultValue(property.PropertyType); - - if (Equals(propertyValue, defaultValue)) - { - throw new InvalidOperationException($"Property '{identifiable.GetClrType().Name}.{IdPropertyName}' should " + - $"have been assigned at this point, but it contains its default {property.PropertyType.Name} value '{propertyValue}'."); - } + throw new InvalidOperationException($"Property '{identifiable.GetClrType().Name}.{IdPropertyName}' should " + + $"have been assigned at this point, but it contains its default {property.PropertyType.Name} value '{propertyValue}'."); } return propertyValue!; From bce1d8f4814abc5ed9c37d9ab2a003bf78f2bf7f Mon Sep 17 00:00:00 2001 From: Bart Koelman <10324372+bkoelman@users.noreply.github.com> Date: Sun, 14 Jul 2024 01:09:19 +0200 Subject: [PATCH 5/5] Fix usage of whitespace IDs when TId is string --- .../Controllers/JsonApiController.cs | 26 +- .../ZeroKeys/EmptyGuidAsKeyTests.cs | 4 +- .../IntegrationTests/ZeroKeys/Game.cs | 3 + .../IntegrationTests/ZeroKeys/Player.cs | 3 +- .../ZeroKeys/WhiteSpaceAsKeyTests.cs | 656 ++++++++++++++++++ .../IntegrationTestContext.cs | 15 + 6 files changed, 692 insertions(+), 15 deletions(-) create mode 100644 test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/WhiteSpaceAsKeyTests.cs diff --git a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs index e5be3cbc71..846fedb28b 100644 --- a/src/JsonApiDotNetCore/Controllers/JsonApiController.cs +++ b/src/JsonApiDotNetCore/Controllers/JsonApiController.cs @@ -51,7 +51,7 @@ public override async Task GetAsync(CancellationToken cancellatio /// [HttpGet("{id}")] [HttpHead("{id}")] - public override async Task GetAsync([Required] [DisallowNull] TId id, CancellationToken cancellationToken) + public override async Task GetAsync([Required(AllowEmptyStrings = true)] [DisallowNull] TId id, CancellationToken cancellationToken) { return await base.GetAsync(id, cancellationToken); } @@ -59,7 +59,7 @@ public override async Task GetAsync([Required] [DisallowNull] TId /// [HttpGet("{id}/{relationshipName}")] [HttpHead("{id}/{relationshipName}")] - public override async Task GetSecondaryAsync([Required] [DisallowNull] TId id, [Required] string relationshipName, + public override async Task GetSecondaryAsync([Required(AllowEmptyStrings = true)] [DisallowNull] TId id, [Required] string relationshipName, CancellationToken cancellationToken) { return await base.GetSecondaryAsync(id, relationshipName, cancellationToken); @@ -68,8 +68,8 @@ public override async Task GetSecondaryAsync([Required] [Disallow /// [HttpGet("{id}/relationships/{relationshipName}")] [HttpHead("{id}/relationships/{relationshipName}")] - public override async Task GetRelationshipAsync([Required] [DisallowNull] TId id, [Required] string relationshipName, - CancellationToken cancellationToken) + public override async Task GetRelationshipAsync([Required(AllowEmptyStrings = true)] [DisallowNull] TId id, + [Required] string relationshipName, CancellationToken cancellationToken) { return await base.GetRelationshipAsync(id, relationshipName, cancellationToken); } @@ -83,39 +83,41 @@ public override async Task PostAsync([Required] TResource resourc /// [HttpPost("{id}/relationships/{relationshipName}")] - public override async Task PostRelationshipAsync([Required] [DisallowNull] TId id, [Required] string relationshipName, - [Required] ISet rightResourceIds, CancellationToken cancellationToken) + public override async Task PostRelationshipAsync([Required(AllowEmptyStrings = true)] [DisallowNull] TId id, + [Required] string relationshipName, [Required] ISet rightResourceIds, CancellationToken cancellationToken) { return await base.PostRelationshipAsync(id, relationshipName, rightResourceIds, cancellationToken); } /// [HttpPatch("{id}")] - public override async Task PatchAsync([Required] [DisallowNull] TId id, [Required] TResource resource, CancellationToken cancellationToken) + public override async Task PatchAsync([Required(AllowEmptyStrings = true)] [DisallowNull] TId id, [Required] TResource resource, + CancellationToken cancellationToken) { return await base.PatchAsync(id, resource, cancellationToken); } /// [HttpPatch("{id}/relationships/{relationshipName}")] + // `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] [DisallowNull] TId id, [Required] string relationshipName, - [Required] object? rightValue, CancellationToken cancellationToken) + public override async Task PatchRelationshipAsync([Required(AllowEmptyStrings = true)] [DisallowNull] TId id, + [Required] string relationshipName, [Required] object? rightValue, CancellationToken cancellationToken) { return await base.PatchRelationshipAsync(id, relationshipName, rightValue, cancellationToken); } /// [HttpDelete("{id}")] - public override async Task DeleteAsync([Required] [DisallowNull] TId id, CancellationToken cancellationToken) + public override async Task DeleteAsync([Required(AllowEmptyStrings = true)] [DisallowNull] TId id, CancellationToken cancellationToken) { return await base.DeleteAsync(id, cancellationToken); } /// [HttpDelete("{id}/relationships/{relationshipName}")] - public override async Task DeleteRelationshipAsync([Required] [DisallowNull] TId id, [Required] string relationshipName, - [Required] ISet rightResourceIds, CancellationToken cancellationToken) + public override async Task DeleteRelationshipAsync([Required(AllowEmptyStrings = true)] [DisallowNull] TId id, + [Required] string relationshipName, [Required] ISet rightResourceIds, CancellationToken cancellationToken) { return await base.DeleteRelationshipAsync(id, relationshipName, rightResourceIds, cancellationToken); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs index 93881be9eb..bde6fca28a 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/EmptyGuidAsKeyTests.cs @@ -572,9 +572,9 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - Map? gameInDatabase = await dbContext.Maps.FirstWithIdOrDefaultAsync(existingMap.Id); + Map? mapInDatabase = await dbContext.Maps.FirstWithIdOrDefaultAsync(existingMap.Id); - gameInDatabase.Should().BeNull(); + mapInDatabase.Should().BeNull(); }); } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/Game.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/Game.cs index 172ab35a2a..55372d4b0f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/Game.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/Game.cs @@ -17,6 +17,9 @@ public sealed class Game : Identifiable [Attr] public Guid SessionToken => Guid.NewGuid(); + [HasOne] + public Player? Host { get; set; } + [HasMany] public ICollection ActivePlayers { get; set; } = new List(); diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/Player.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/Player.cs index b2e7cf2c58..52f52d112c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/Player.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/Player.cs @@ -1,11 +1,12 @@ using JetBrains.Annotations; +using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; namespace JsonApiDotNetCoreTests.IntegrationTests.ZeroKeys; [UsedImplicitly(ImplicitUseTargetFlags.Members)] -[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.ZeroKeys")] +[Resource(ControllerNamespace = "JsonApiDotNetCoreTests.IntegrationTests.ZeroKeys", ClientIdGeneration = ClientIdGenerationMode.Allowed)] public sealed class Player : Identifiable { [Attr] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/WhiteSpaceAsKeyTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/WhiteSpaceAsKeyTests.cs new file mode 100644 index 0000000000..025d8df152 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ZeroKeys/WhiteSpaceAsKeyTests.cs @@ -0,0 +1,656 @@ +using System.Net; +using System.Reflection; +using FluentAssertions; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.ZeroKeys; + +public sealed class WhiteSpaceAsKeyTests : IClassFixture, ZeroKeyDbContext>> +{ + // An empty string id makes no sense: get-by-id, update and delete resource are impossible, and rendered links are unusable. + private const string SingleSpace = " "; + + private readonly IntegrationTestContext, ZeroKeyDbContext> _testContext; + private readonly ZeroKeyFakers _fakers = new(); + + public WhiteSpaceAsKeyTests(IntegrationTestContext, ZeroKeyDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + + testContext.PostConfigureServices(services => + { + ServiceDescriptor serviceDescriptor = services.Single(descriptor => descriptor.ServiceType == typeof(IModelMetadataProvider)); + services.Remove(serviceDescriptor); + Type existingProviderType = serviceDescriptor.ImplementationType!; + + services.AddSingleton(serviceProvider => + { + var existingProvider = (ModelMetadataProvider)ActivatorUtilities.CreateInstance(serviceProvider, existingProviderType); + return new PreserveWhitespaceModelMetadataProvider(existingProvider); + }); + }); + + var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); + options.UseRelativeLinks = true; + } + + [Fact] + public async Task Can_filter_by_space_ID_on_primary_resources() + { + // Arrange + List players = _fakers.Player.Generate(2); + players[0].Id = SingleSpace; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Players.AddRange(players); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/players?filter=equals(id,' ')"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.ManyValue.ShouldHaveCount(1); + responseDocument.Data.ManyValue[0].Id.Should().Be(SingleSpace); + + responseDocument.Data.ManyValue[0].With(resource => + { + resource.Links.ShouldNotBeNull(); + resource.Links.Self.Should().Be("/players/%20"); + }); + } + + [Fact] + public async Task Can_get_primary_resource_by_space_ID_with_include() + { + // Arrange + Player player = _fakers.Player.Generate(); + player.Id = SingleSpace; + player.ActiveGame = _fakers.Game.Generate(); + player.ActiveGame.Id = 0; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTablesAsync(); + dbContext.Players.Add(player); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/players/%20?include=activeGame"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.OK); + + responseDocument.Data.SingleValue.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Id.Should().Be(SingleSpace); + responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); + responseDocument.Data.SingleValue.Links.Self.Should().Be("/players/%20"); + + responseDocument.Included.ShouldHaveCount(1); + responseDocument.Included[0].Id.Should().Be("0"); + } + + [Fact] + public async Task Can_create_resource_with_space_ID() + { + // Arrange + string newEmailAddress = _fakers.Player.Generate().EmailAddress; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + }); + + var requestBody = new + { + data = new + { + type = "players", + id = SingleSpace, + attributes = new + { + emailAddress = newEmailAddress + } + } + }; + + const string route = "/players"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + httpResponse.Headers.Location.Should().Be("/players/%20"); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Player playerInDatabase = await dbContext.Players.FirstWithIdAsync((string?)SingleSpace); + + playerInDatabase.ShouldNotBeNull(); + playerInDatabase.EmailAddress.Should().Be(newEmailAddress); + }); + } + + [Fact] + public async Task Can_update_resource_with_space_ID() + { + // Arrange + Player existingPlayer = _fakers.Player.Generate(); + existingPlayer.Id = SingleSpace; + + string newEmailAddress = _fakers.Player.Generate().EmailAddress; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Players.Add(existingPlayer); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "players", + id = SingleSpace, + attributes = new + { + emailAddress = newEmailAddress + } + } + }; + + const string route = "/players/%20"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Player playerInDatabase = await dbContext.Players.FirstWithIdAsync((string?)SingleSpace); + + playerInDatabase.ShouldNotBeNull(); + playerInDatabase.EmailAddress.Should().Be(newEmailAddress); + }); + } + + [Fact] + public async Task Can_clear_ToOne_relationship_with_space_ID() + { + // Arrange + Game existingGame = _fakers.Game.Generate(); + existingGame.Host = _fakers.Player.Generate(); + existingGame.Host.Id = string.Empty; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Games.Add(existingGame); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = (object?)null + }; + + string route = $"/games/{existingGame.StringId}/relationships/host"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Game gameInDatabase = await dbContext.Games.Include(game => game.Host).FirstWithIdAsync(existingGame.Id); + + gameInDatabase.ShouldNotBeNull(); + gameInDatabase.Host.Should().BeNull(); + }); + } + + [Fact] + public async Task Can_assign_ToOne_relationship_with_space_ID() + { + // Arrange + Game existingGame = _fakers.Game.Generate(); + + Player existingPlayer = _fakers.Player.Generate(); + existingPlayer.Id = SingleSpace; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddInRange(existingGame, existingPlayer); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "players", + id = SingleSpace + } + }; + + string route = $"/games/{existingGame.StringId}/relationships/host"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Game gameInDatabase = await dbContext.Games.Include(game => game.Host).FirstWithIdAsync(existingGame.Id); + + gameInDatabase.ShouldNotBeNull(); + gameInDatabase.Host.ShouldNotBeNull(); + gameInDatabase.Host.Id.Should().Be(SingleSpace); + }); + } + + [Fact] + public async Task Can_replace_ToOne_relationship_with_space_ID() + { + // Arrange + Game existingGame = _fakers.Game.Generate(); + existingGame.Host = _fakers.Player.Generate(); + + Player existingPlayer = _fakers.Player.Generate(); + existingPlayer.Id = SingleSpace; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddInRange(existingGame, existingPlayer); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new + { + type = "players", + id = SingleSpace + } + }; + + string route = $"/games/{existingGame.StringId}/relationships/host"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Game gameInDatabase = await dbContext.Games.Include(game => game.Host).FirstWithIdAsync(existingGame.Id); + + gameInDatabase.ShouldNotBeNull(); + gameInDatabase.Host.ShouldNotBeNull(); + gameInDatabase.Host.Id.Should().Be(SingleSpace); + }); + } + + [Fact] + public async Task Can_clear_ToMany_relationship_with_space_ID() + { + // Arrange + Game existingGame = _fakers.Game.Generate(); + existingGame.ActivePlayers = _fakers.Player.Generate(2); + existingGame.ActivePlayers.ElementAt(0).Id = SingleSpace; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Games.Add(existingGame); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = Array.Empty() + }; + + string route = $"/games/{existingGame.StringId}/relationships/activePlayers"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Game gameInDatabase = await dbContext.Games.Include(game => game.ActivePlayers).FirstWithIdAsync(existingGame.Id); + + gameInDatabase.ShouldNotBeNull(); + gameInDatabase.ActivePlayers.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Can_assign_ToMany_relationship_with_space_ID() + { + // Arrange + Game existingGame = _fakers.Game.Generate(); + + Player existingPlayer = _fakers.Player.Generate(); + existingPlayer.Id = SingleSpace; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddInRange(existingGame, existingPlayer); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "players", + id = SingleSpace + } + } + }; + + string route = $"/games/{existingGame.StringId}/relationships/activePlayers"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Game gameInDatabase = await dbContext.Games.Include(game => game.ActivePlayers).FirstWithIdAsync(existingGame.Id); + + gameInDatabase.ShouldNotBeNull(); + gameInDatabase.ActivePlayers.ShouldHaveCount(1); + gameInDatabase.ActivePlayers.ElementAt(0).Id.Should().Be(SingleSpace); + }); + } + + [Fact] + public async Task Can_replace_ToMany_relationship_with_space_ID() + { + // Arrange + Game existingGame = _fakers.Game.Generate(); + existingGame.ActivePlayers = _fakers.Player.Generate(2); + + Player existingPlayer = _fakers.Player.Generate(); + existingPlayer.Id = SingleSpace; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddInRange(existingGame, existingPlayer); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "players", + id = SingleSpace + } + } + }; + + string route = $"/games/{existingGame.StringId}/relationships/activePlayers"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Game gameInDatabase = await dbContext.Games.Include(game => game.ActivePlayers).FirstWithIdAsync(existingGame.Id); + + gameInDatabase.ShouldNotBeNull(); + gameInDatabase.ActivePlayers.ShouldHaveCount(1); + gameInDatabase.ActivePlayers.ElementAt(0).Id.Should().Be(SingleSpace); + }); + } + + [Fact] + public async Task Can_add_to_ToMany_relationship_with_space_ID() + { + // Arrange + Game existingGame = _fakers.Game.Generate(); + existingGame.ActivePlayers = _fakers.Player.Generate(1); + + Player existingPlayer = _fakers.Player.Generate(); + existingPlayer.Id = SingleSpace; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.AddInRange(existingGame, existingPlayer); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "players", + id = SingleSpace + } + } + }; + + string route = $"/games/{existingGame.StringId}/relationships/activePlayers"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Game gameInDatabase = await dbContext.Games.Include(game => game.ActivePlayers).FirstWithIdAsync(existingGame.Id); + + gameInDatabase.ShouldNotBeNull(); + gameInDatabase.ActivePlayers.ShouldHaveCount(2); + gameInDatabase.ActivePlayers.Should().ContainSingle(player => player.Id == SingleSpace); + }); + } + + [Fact] + public async Task Can_remove_from_ToMany_relationship_with_space_ID() + { + // Arrange + Game existingGame = _fakers.Game.Generate(); + existingGame.ActivePlayers = _fakers.Player.Generate(2); + existingGame.ActivePlayers.ElementAt(0).Id = SingleSpace; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Games.Add(existingGame); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + data = new[] + { + new + { + type = "players", + id = SingleSpace + } + } + }; + + string route = $"/games/{existingGame.StringId}/relationships/activePlayers"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Game gameInDatabase = await dbContext.Games.Include(game => game.ActivePlayers).FirstWithIdAsync(existingGame.Id); + + gameInDatabase.ShouldNotBeNull(); + gameInDatabase.ActivePlayers.ShouldHaveCount(1); + gameInDatabase.ActivePlayers.Should().ContainSingle(player => player.Id != SingleSpace); + }); + } + + [Fact] + public async Task Can_delete_resource_with_space_ID() + { + // Arrange + Player existingPlayer = _fakers.Player.Generate(); + existingPlayer.Id = SingleSpace; + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.Players.Add(existingPlayer); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/players/%20"; + + // Act + (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NoContent); + + responseDocument.Should().BeEmpty(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + Player? playerInDatabase = await dbContext.Players.FirstWithIdOrDefaultAsync((string?)existingPlayer.Id); + + playerInDatabase.Should().BeNull(); + }); + } + + private sealed class PreserveWhitespaceModelMetadataProvider : ModelMetadataProvider + { + private readonly ModelMetadataProvider _innerProvider; + + public PreserveWhitespaceModelMetadataProvider(ModelMetadataProvider innerProvider) + { + ArgumentGuard.NotNull(innerProvider); + + _innerProvider = innerProvider; + } + + public override ModelMetadata GetMetadataForType(Type modelType) + { + var metadata = (DefaultModelMetadata)_innerProvider.GetMetadataForType(modelType); + + TurnOffConvertEmptyStringToNull(metadata); + + return metadata; + } + + public override IEnumerable GetMetadataForProperties(Type modelType) + { + return _innerProvider.GetMetadataForProperties(modelType); + } + + public override ModelMetadata GetMetadataForParameter(ParameterInfo parameter) + { + var metadata = (DefaultModelMetadata)_innerProvider.GetMetadataForParameter(parameter); + + TurnOffConvertEmptyStringToNull(metadata); + + return metadata; + } + + public override ModelMetadata GetMetadataForParameter(ParameterInfo parameter, Type modelType) + { + return _innerProvider.GetMetadataForParameter(parameter, modelType); + } + + public override ModelMetadata GetMetadataForProperty(PropertyInfo propertyInfo, Type modelType) + { + return _innerProvider.GetMetadataForProperty(propertyInfo, modelType); + } + + public override ModelMetadata GetMetadataForConstructor(ConstructorInfo constructor, Type modelType) + { + return _innerProvider.GetMetadataForConstructor(constructor, modelType); + } + + private static void TurnOffConvertEmptyStringToNull(DefaultModelMetadata metadata) + { + // https://github.com/dotnet/aspnetcore/issues/29948#issuecomment-2058747809 + metadata.DisplayMetadata.ConvertEmptyStringToNull = false; + } + } +} diff --git a/test/TestBuildingBlocks/IntegrationTestContext.cs b/test/TestBuildingBlocks/IntegrationTestContext.cs index 83f00667b2..f7376741ce 100644 --- a/test/TestBuildingBlocks/IntegrationTestContext.cs +++ b/test/TestBuildingBlocks/IntegrationTestContext.cs @@ -34,6 +34,7 @@ public class IntegrationTestContext : IntegrationTest private readonly TestControllerProvider _testControllerProvider = new(); private Action? _loggingConfiguration; private Action? _configureServices; + private Action? _postConfigureServices; protected override JsonSerializerOptions SerializerOptions { @@ -83,6 +84,8 @@ private WebApplicationFactory CreateFactory() }); }); + factory.PostConfigureServices(_postConfigureServices); + // We have placed an appsettings.json in the TestBuildingBlocks project directory and set the content root to there. Note that // controllers are not discovered in the content root, but are registered manually using IntegrationTestContext.UseController. WebApplicationFactory factoryWithConfiguredContentRoot = @@ -113,6 +116,11 @@ public void ConfigureServices(Action configureServices) _configureServices = configureServices; } + public void PostConfigureServices(Action configureServices) + { + _postConfigureServices = configureServices; + } + public async Task RunOnDatabaseAsync(Func asyncAction) { await using AsyncServiceScope scope = Factory.Services.CreateAsyncScope(); @@ -141,6 +149,7 @@ private sealed class IntegrationTestWebApplicationFactory : WebApplicationFactor { private Action? _loggingConfiguration; private Action? _configureServices; + private Action? _postConfigureServices; public void ConfigureLogging(Action? loggingConfiguration) { @@ -152,6 +161,11 @@ public void ConfigureServices(Action? configureServices) _configureServices = configureServices; } + public void PostConfigureServices(Action? configureServices) + { + _postConfigureServices = configureServices; + } + protected override IHostBuilder CreateHostBuilder() { // @formatter:wrap_chained_method_calls chop_always @@ -172,6 +186,7 @@ protected override IHostBuilder CreateHostBuilder() { webBuilder.ConfigureServices(services => _configureServices?.Invoke(services)); webBuilder.UseStartup(); + webBuilder.ConfigureServices(services => _postConfigureServices?.Invoke(services)); }) .ConfigureLogging(options => _loggingConfiguration?.Invoke(options));