Skip to content

Commit 1b89742

Browse files
bkoelmanverdie-g
andauthored
OpenAPI: Client-generated IDs (#1494)
Co-authored-by: Grégoire <gregoire.verdier@gmail.com>
1 parent 66cf92f commit 1b89742

File tree

9 files changed

+1561
-0
lines changed

9 files changed

+1561
-0
lines changed
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
using FluentAssertions;
2+
using FluentAssertions.Specialized;
3+
using JsonApiDotNetCore.OpenApi.Client.NSwag;
4+
using Newtonsoft.Json;
5+
using OpenApiNSwagEndToEndTests.ClientIdGenerationModes.GeneratedCode;
6+
using OpenApiTests;
7+
using OpenApiTests.ClientIdGenerationModes;
8+
using TestBuildingBlocks;
9+
using Xunit;
10+
11+
namespace OpenApiNSwagEndToEndTests.ClientIdGenerationModes;
12+
13+
public sealed class ClientIdGenerationModesTests
14+
: IClassFixture<IntegrationTestContext<OpenApiStartup<ClientIdGenerationModesDbContext>, ClientIdGenerationModesDbContext>>
15+
{
16+
private readonly IntegrationTestContext<OpenApiStartup<ClientIdGenerationModesDbContext>, ClientIdGenerationModesDbContext> _testContext;
17+
private readonly ClientIdGenerationModesFakers _fakers = new();
18+
19+
public ClientIdGenerationModesTests(IntegrationTestContext<OpenApiStartup<ClientIdGenerationModesDbContext>, ClientIdGenerationModesDbContext> testContext)
20+
{
21+
_testContext = testContext;
22+
23+
testContext.UseController<PlayersController>();
24+
testContext.UseController<GamesController>();
25+
testContext.UseController<PlayerGroupsController>();
26+
}
27+
28+
[Fact]
29+
public async Task Cannot_create_resource_without_ID_when_supplying_ID_is_required()
30+
{
31+
// Arrange
32+
Player player = _fakers.Player.Generate();
33+
34+
using HttpClient httpClient = _testContext.Factory.CreateClient();
35+
ClientIdGenerationModesClient apiClient = new(httpClient);
36+
37+
// Act
38+
Func<Task<PlayerPrimaryResponseDocument?>> action = () => ApiResponse.TranslateAsync(() => apiClient.PostPlayerAsync(null, new PlayerPostRequestDocument
39+
{
40+
Data = new PlayerDataInPostRequest
41+
{
42+
Id = null!,
43+
Attributes = new PlayerAttributesInPostRequest
44+
{
45+
UserName = player.UserName
46+
}
47+
}
48+
}));
49+
50+
// Assert
51+
ExceptionAssertions<JsonSerializationException> assertion = await action.Should().ThrowExactlyAsync<JsonSerializationException>();
52+
assertion.Which.Message.Should().Be("Cannot write a null value for property 'id'. Property requires a value. Path 'data'.");
53+
}
54+
55+
[Fact]
56+
public async Task Can_create_resource_with_ID_when_supplying_ID_is_required()
57+
{
58+
// Arrange
59+
Player player = _fakers.Player.Generate();
60+
player.Id = Guid.NewGuid();
61+
62+
using HttpClient httpClient = _testContext.Factory.CreateClient();
63+
ClientIdGenerationModesClient apiClient = new(httpClient);
64+
65+
// Act
66+
PlayerPrimaryResponseDocument? document = await ApiResponse.TranslateAsync(() => apiClient.PostPlayerAsync(null, new PlayerPostRequestDocument
67+
{
68+
Data = new PlayerDataInPostRequest
69+
{
70+
Id = player.StringId!,
71+
Attributes = new PlayerAttributesInPostRequest
72+
{
73+
UserName = player.UserName
74+
}
75+
}
76+
}));
77+
78+
// Assert
79+
document.Should().BeNull();
80+
81+
await _testContext.RunOnDatabaseAsync(async dbContext =>
82+
{
83+
Player playerInDatabase = await dbContext.Players.FirstWithIdAsync(player.Id);
84+
85+
playerInDatabase.UserName.Should().Be(player.UserName);
86+
});
87+
}
88+
89+
[Fact]
90+
public async Task Can_create_resource_without_ID_when_supplying_ID_is_allowed()
91+
{
92+
// Arrange
93+
Game game = _fakers.Game.Generate();
94+
95+
using HttpClient httpClient = _testContext.Factory.CreateClient();
96+
ClientIdGenerationModesClient apiClient = new(httpClient);
97+
98+
// Act
99+
GamePrimaryResponseDocument? document = await ApiResponse.TranslateAsync(() => apiClient.PostGameAsync(null, new GamePostRequestDocument
100+
{
101+
Data = new GameDataInPostRequest
102+
{
103+
Id = null!,
104+
Attributes = new GameAttributesInPostRequest
105+
{
106+
Title = game.Title,
107+
PurchasePrice = (double)game.PurchasePrice
108+
}
109+
}
110+
}));
111+
112+
// Assert
113+
document.ShouldNotBeNull();
114+
document.Data.Id.Should().NotBeNullOrEmpty();
115+
116+
await _testContext.RunOnDatabaseAsync(async dbContext =>
117+
{
118+
Game gameInDatabase = await dbContext.Games.FirstWithIdAsync(Guid.Parse(document.Data.Id));
119+
120+
gameInDatabase.Title.Should().Be(game.Title);
121+
gameInDatabase.PurchasePrice.Should().Be(game.PurchasePrice);
122+
});
123+
}
124+
125+
[Fact]
126+
public async Task Can_create_resource_with_ID_when_supplying_ID_is_allowed()
127+
{
128+
// Arrange
129+
Game game = _fakers.Game.Generate();
130+
game.Id = Guid.NewGuid();
131+
132+
using HttpClient httpClient = _testContext.Factory.CreateClient();
133+
ClientIdGenerationModesClient apiClient = new(httpClient);
134+
135+
// Act
136+
GamePrimaryResponseDocument? document = await ApiResponse.TranslateAsync(() => apiClient.PostGameAsync(null, new GamePostRequestDocument
137+
{
138+
Data = new GameDataInPostRequest
139+
{
140+
Id = game.StringId!,
141+
Attributes = new GameAttributesInPostRequest
142+
{
143+
Title = game.Title,
144+
PurchasePrice = (double)game.PurchasePrice
145+
}
146+
}
147+
}));
148+
149+
// Assert
150+
document.Should().BeNull();
151+
152+
await _testContext.RunOnDatabaseAsync(async dbContext =>
153+
{
154+
Game gameInDatabase = await dbContext.Games.FirstWithIdAsync(game.Id);
155+
156+
gameInDatabase.Title.Should().Be(game.Title);
157+
gameInDatabase.PurchasePrice.Should().Be(game.PurchasePrice);
158+
});
159+
}
160+
161+
[Fact]
162+
public async Task Can_create_resource_without_ID_when_supplying_ID_is_forbidden()
163+
{
164+
// Arrange
165+
PlayerGroup playerGroup = _fakers.Group.Generate();
166+
167+
using HttpClient httpClient = _testContext.Factory.CreateClient();
168+
ClientIdGenerationModesClient apiClient = new(httpClient);
169+
170+
// Act
171+
PlayerGroupPrimaryResponseDocument? document = await ApiResponse.TranslateAsync(() => apiClient.PostPlayerGroupAsync(null,
172+
new PlayerGroupPostRequestDocument
173+
{
174+
Data = new PlayerGroupDataInPostRequest
175+
{
176+
Attributes = new PlayerGroupAttributesInPostRequest
177+
{
178+
Name = playerGroup.Name
179+
}
180+
}
181+
}));
182+
183+
// Assert
184+
document.ShouldNotBeNull();
185+
document.Data.Id.Should().NotBeNullOrEmpty();
186+
187+
await _testContext.RunOnDatabaseAsync(async dbContext =>
188+
{
189+
PlayerGroup playerGroupInDatabase = await dbContext.PlayerGroups.FirstWithIdAsync(long.Parse(document.Data.Id));
190+
191+
playerGroupInDatabase.Name.Should().Be(playerGroup.Name);
192+
});
193+
}
194+
}

test/OpenApiNSwagEndToEndTests/OpenApiNSwagEndToEndTests.csproj

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@
2323
</ItemGroup>
2424

2525
<ItemGroup>
26+
<OpenApiReference Include="..\OpenApiTests\ClientIdGenerationModes\GeneratedSwagger\swagger.g.json">
27+
<Namespace>OpenApiNSwagEndToEndTests.ClientIdGenerationModes.GeneratedCode</Namespace>
28+
<ClassName>ClientIdGenerationModesClient</ClassName>
29+
<OutputPath>ClientIdGenerationModesClient.cs</OutputPath>
30+
<CodeGenerator>NSwagCSharp</CodeGenerator>
31+
<Options>/ClientClassAccessModifier:internal /GenerateExceptionClasses:false /AdditionalNamespaceUsages:JsonApiDotNetCore.OpenApi.Client.NSwag /GenerateNullableReferenceTypes:true</Options>
32+
</OpenApiReference>
2633
<OpenApiReference Include="..\OpenApiTests\Headers\GeneratedSwagger\swagger.g.json">
2734
<Namespace>OpenApiNSwagEndToEndTests.Headers.GeneratedCode</Namespace>
2835
<ClassName>HeadersClient</ClassName>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using JetBrains.Annotations;
2+
using Microsoft.EntityFrameworkCore;
3+
using TestBuildingBlocks;
4+
5+
namespace OpenApiTests.ClientIdGenerationModes;
6+
7+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
8+
public sealed class ClientIdGenerationModesDbContext(DbContextOptions<ClientIdGenerationModesDbContext> options) : TestableDbContext(options)
9+
{
10+
public DbSet<Player> Players => Set<Player>();
11+
public DbSet<Game> Games => Set<Game>();
12+
public DbSet<PlayerGroup> PlayerGroups => Set<PlayerGroup>();
13+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
using Bogus;
2+
using JetBrains.Annotations;
3+
using TestBuildingBlocks;
4+
5+
// @formatter:wrap_chained_method_calls chop_if_long
6+
// @formatter:wrap_before_first_method_call true
7+
8+
namespace OpenApiTests.ClientIdGenerationModes;
9+
10+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
11+
public sealed class ClientIdGenerationModesFakers : FakerContainer
12+
{
13+
private readonly Lazy<Faker<Player>> _lazyPlayerFaker = new(() => new Faker<Player>()
14+
.UseSeed(GetFakerSeed())
15+
.RuleFor(player => player.UserName, faker => faker.Person.UserName));
16+
17+
private readonly Lazy<Faker<Game>> _lazyGameFaker = new(() => new Faker<Game>()
18+
.UseSeed(GetFakerSeed())
19+
.RuleFor(game => game.Title, faker => faker.Commerce.ProductName())
20+
.RuleFor(game => game.PurchasePrice, faker => faker.Finance.Amount(1, 80)));
21+
22+
private readonly Lazy<Faker<PlayerGroup>> _lazyGroupFaker = new(() => new Faker<PlayerGroup>()
23+
.UseSeed(GetFakerSeed())
24+
.RuleFor(playerGroup => playerGroup.Name, faker => faker.Person.Company.Name));
25+
26+
public Faker<Player> Player => _lazyPlayerFaker.Value;
27+
public Faker<Game> Game => _lazyGameFaker.Value;
28+
public Faker<PlayerGroup> Group => _lazyGroupFaker.Value;
29+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
using System.Text.Json;
2+
using TestBuildingBlocks;
3+
using Xunit;
4+
5+
namespace OpenApiTests.ClientIdGenerationModes;
6+
7+
public sealed class ClientIdGenerationModesTests
8+
: IClassFixture<OpenApiTestContext<OpenApiStartup<ClientIdGenerationModesDbContext>, ClientIdGenerationModesDbContext>>
9+
{
10+
private readonly OpenApiTestContext<OpenApiStartup<ClientIdGenerationModesDbContext>, ClientIdGenerationModesDbContext> _testContext;
11+
12+
public ClientIdGenerationModesTests(OpenApiTestContext<OpenApiStartup<ClientIdGenerationModesDbContext>, ClientIdGenerationModesDbContext> testContext)
13+
{
14+
_testContext = testContext;
15+
16+
testContext.UseController<PlayersController>();
17+
testContext.UseController<GamesController>();
18+
testContext.UseController<PlayerGroupsController>();
19+
20+
testContext.SwaggerDocumentOutputDirectory = $"{GetType().Namespace!.Replace('.', '/')}/GeneratedSwagger";
21+
}
22+
23+
[Fact]
24+
public async Task Schema_property_for_ID_is_required_in_post_request()
25+
{
26+
// Act
27+
JsonElement document = await _testContext.GetSwaggerDocumentAsync();
28+
29+
// Assert
30+
document.Should().ContainPath("components.schemas.playerDataInPostRequest").With(dataElement =>
31+
{
32+
dataElement.Should().ContainPath("required").With(requiredElement =>
33+
{
34+
requiredElement.Should().ContainArrayElement("id");
35+
});
36+
37+
dataElement.Should().ContainPath("properties.id");
38+
});
39+
}
40+
41+
[Fact]
42+
public async Task Schema_property_for_ID_is_optional_in_post_request()
43+
{
44+
// Act
45+
JsonElement document = await _testContext.GetSwaggerDocumentAsync();
46+
47+
// Assert
48+
document.Should().ContainPath("components.schemas.gameDataInPostRequest").With(dataElement =>
49+
{
50+
dataElement.Should().ContainPath("required").With(requiredElement =>
51+
{
52+
requiredElement.Should().NotContainArrayElement("id");
53+
});
54+
55+
dataElement.Should().ContainPath("properties.id");
56+
});
57+
}
58+
59+
[Fact]
60+
public async Task Schema_property_for_ID_is_omitted_in_post_request()
61+
{
62+
// Act
63+
JsonElement document = await _testContext.GetSwaggerDocumentAsync();
64+
65+
// Assert
66+
document.Should().ContainPath("components.schemas.playerGroupDataInPostRequest").With(dataElement =>
67+
{
68+
dataElement.Should().ContainPath("required").With(requiredElement =>
69+
{
70+
requiredElement.Should().NotContainArrayElement("id");
71+
});
72+
73+
dataElement.Should().NotContainPath("properties.id");
74+
});
75+
}
76+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using JetBrains.Annotations;
2+
using JsonApiDotNetCore.Configuration;
3+
using JsonApiDotNetCore.Controllers;
4+
using JsonApiDotNetCore.Resources;
5+
using JsonApiDotNetCore.Resources.Annotations;
6+
7+
namespace OpenApiTests.ClientIdGenerationModes;
8+
9+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
10+
[Resource(ControllerNamespace = "OpenApiTests.ClientIdGenerationModes", ClientIdGeneration = ClientIdGenerationMode.Allowed,
11+
GenerateControllerEndpoints = JsonApiEndpoints.Post)]
12+
public sealed class Game : Identifiable<Guid>
13+
{
14+
[Attr]
15+
public string Title { get; set; } = null!;
16+
17+
[Attr]
18+
public decimal PurchasePrice { get; set; }
19+
}

0 commit comments

Comments
 (0)