Skip to content

Commit c48dea3

Browse files
maureibkoelman
andauthored
Support NRT and MSV in required and nullable (#1185)
* Added tests for nullable and required properties in schema generation * Added handling of modelstate validation in setting required attributes * Not all swagger documents need to be saved to disk; changes in OpenApiTestContext to allow for this * Added OpenApi client tests for nullable and required properties * Use NullabilityInfoContext for nullability information * Post-merge fixes * Post-merge fixes * Fixed: do not share NullabilityInfoContext, it is not thread-safe * Review feedback OpenApiTests/SchemaProperties test collection: * Allow for usage of OpenApiStartup directly * Remove unneeded adding of JsonConverter * Replace null checks with .ShouldHaveCount() calls * Adjust test names Misc: * Reverted .editorconfig changes and fixed ApiException constructor instead * Remove Debug statement * remove redundant new lines in eof added by cleanupcode * Improved naming in OpenApiTests/SchemaProperties * Review feedback: NullabilityTests * Improved JsonApiClient and testing SchemaProperty Tests: * More rigorous test suite, see PR description IJsonApiClient: * Renamed registration method to a more functionally descriptive name. * Improved documentation to contain most relevant information on top instead of at the end, and removed ambiguigity in wording. JsonApiClient * Fix bug: disallow omitting members that are explicitly required by the OAS description * Renamed AttributeNamesContainer to AttributesObjectContext because it was more than just a container * Misc: better variable naming * Fix test: should not omit required field in test request body * Temp enable CI buid for current branch * Rename test files: it no longer only concerns required attributes, but more generally request behaviour * Changes and tests for support of nullable and required for relationships * - Rename placeholder model names and properties to examples consisent with existing test suite - Use existing DbContext instead of temporary one * Move into consistent folder structure, remove bad cleanupcode eof linebreaks * Organise tests such that they map directly to the tables in #1231 and #1111 Organise tests such that they map directly to the tables in #1231 and #1111 * Add two missing 'Act' comments * More elaborate testing -> in sync with latest version of nullability/required table -> introduces ResourceFieldValidationMetadataProvider -> Fix test in legacy projects -> Reusable faker building block for OpenApiClient related concerns * Remove non-sensical testcases. Add caching in ObjectExtensions. * Remove overlooked code duplication in OpenApiTests, revert reflection caching in object extension * Make AutoFakers deterministic; generate positive IDs * Fix nameof * Use On/Off naming, shorten type names by using Nrt+Msv * Renamed EmptyResource to Empty to further shorten FK names * Fixed invalid EF Core mappings, resulting in logged warnings and inability to clear optional to-one relationship when NRT off; fixed wrong public names * Move misplaced Act comments * Optimize and clarify ResourceFieldValidationMetadataProvider * Rename method, provide error message * Refactor JsonApiClient: simplified recursion by using two converters, clearer naming, separation of concerns, improved error message * Add relationship nullability assertions in OpenAPI client tests * Cleanup JsonElementExtensions * Cleanup ObjectExtensions * Make base type abstract, remove redundant TranslateAsync calls, inline relationship Data property name * Simplify usings * Sync up test names * Fix invalid tests * Fix assertion messages * Sync up tests * Revert change to pass full options instead of just the naming policy * Fix casing in test names * Simplify Cannot_exclude_Id tests * Rename base type to avoid OpenApiClientTests.OpenApiClientTests * Adapt to existing naming convention * Remove redundant assertions, fix formatting * Correct test names * Centralize code for property assignment in tests * Apply Resharper hint: convert switch statement to expression * Simplify expressions * Simplify exception assertions * Use string interpolation * Corrections in openapi documentation * Simplify code * Remove redundant suppression * Combine OpenAPI client tests for create resource with null/default attribute * Fixup OpenAPI example and docs * Revert "Merge branch 'master' into openapi-required-and-nullable-properties" This reverts commit 66a2dc4, reversing changes made to c3c4844. * Workaround for running OpenAPI tests on Windows * Address failing InspectCode * Remove redundant calls * Remove redundant tests * Move types out of the wrong namespace * Remove redundant suppressions in openapi after update to CSharpGuidelinesAnalyzer v3.8.4 --------- Co-authored-by: Bart Koelman <10324372+bkoelman@users.noreply.github.com>
1 parent 16a0026 commit c48dea3

File tree

102 files changed

+12343
-909
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

102 files changed

+12343
-909
lines changed

docs/usage/openapi-client.md

Lines changed: 52 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,46 +2,56 @@
22

33
You can generate a JSON:API client in various programming languages from the [OpenAPI specification](https://swagger.io/specification/) file that JsonApiDotNetCore APIs provide.
44

5-
For C# .NET clients generated using [NSwag](https://github.com/RicoSuter/NSwag), we provide an additional package that introduces support for partial PATCH/POST requests. The issue here is that a property on a generated C# class being `null` could mean "set the value to `null` in the request" or "this is `null` because I never touched it".
5+
For C# .NET clients generated using [NSwag](https://github.com/RicoSuter/NSwag), we provide an additional package
6+
that introduces support for partial PATCH/POST requests. The concern here is that a property on a generated C# class
7+
being `null` could mean "set the value to `null` in the request" or "this is `null` because I never touched it".
68

79
## Getting started
810

911
### Visual Studio
1012

11-
The easiest way to get started is by using the built-in capabilities of Visual Studio. The next steps describe how to generate a JSON:API client library and use our package.
13+
The easiest way to get started is by using the built-in capabilities of Visual Studio.
14+
The next steps describe how to generate a JSON:API client library and use our package.
1215

1316
1. In **Solution Explorer**, right-click your client project, select **Add** > **Service Reference** and choose **OpenAPI**.
1417

1518
2. On the next page, specify the OpenAPI URL to your JSON:API server, for example: `http://localhost:14140/swagger/v1/swagger.json`.
16-
Optionally provide a class name and namespace and click **Finish**.
17-
Visual Studio now downloads your swagger.json and updates your project file. This results in a pre-build step that generates the client code.
19+
Specify `ExampleApiClient` as class name, optionally provide a namespace and click **Finish**.
20+
Visual Studio now downloads your swagger.json and updates your project file.
21+
This adds a pre-build step that generates the client code.
1822

19-
Tip: To later re-download swagger.json and regenerate the client code, right-click **Dependencies** > **Manage Connected Services** and click the **Refresh** icon.
20-
3. Although not strictly required, we recommend to run package update now, which fixes some issues and removes the `Stream` parameter from generated calls.
23+
> [!TIP]
24+
> To later re-download swagger.json and regenerate the client code,
25+
> right-click **Dependencies** > **Manage Connected Services** and click the **Refresh** icon.
2126
22-
4. Add some demo code that calls one of your JSON:API endpoints. For example:
27+
3. Although not strictly required, we recommend to run package update now, which fixes some issues.
28+
29+
4. Add code that calls one of your JSON:API endpoints.
2330

2431
```c#
2532
using var httpClient = new HttpClient();
2633
var apiClient = new ExampleApiClient("http://localhost:14140", httpClient);
2734

28-
PersonCollectionResponseDocument getResponse =
29-
await apiClient.GetPersonCollectionAsync();
35+
PersonCollectionResponseDocument getResponse = await apiClient.GetPersonCollectionAsync();
3036

3137
foreach (PersonDataInResponse person in getResponse.Data)
3238
{
33-
Console.WriteLine($"Found user {person.Id} named " +
34-
$"'{person.Attributes.FirstName} {person.Attributes.LastName}'.");
39+
Console.WriteLine($"Found person {person.Id}: {person.Attributes.DisplayName}");
3540
}
3641
```
3742

3843
5. Add our client package to your project:
3944

40-
```
41-
dotnet add package JsonApiDotNetCore.OpenApi.Client
42-
```
45+
```
46+
dotnet add package JsonApiDotNetCore.OpenApi.Client
47+
```
48+
49+
6. Add the following glue code to connect our package with your generated code.
4350

44-
6. Add the following glue code to connect our package with your generated code. The code below assumes you specified `ExampleApiClient` as class name in step 2.
51+
> [!NOTE]
52+
> The class name must be the same as specified in step 2.
53+
> If you also specified a namespace, put this class in the same namespace.
54+
> For example, add `namespace GeneratedCode;` below the `using` lines.
4555

4656
```c#
4757
using JsonApiDotNetCore.OpenApi.Client;
@@ -56,6 +66,9 @@ The easiest way to get started is by using the built-in capabilities of Visual S
5666
}
5767
```
5868

69+
> [!TIP]
70+
> The project at src/Examples/JsonApiDotNetCoreExampleClient contains an enhanced version that logs the HTTP requests and responses.
71+
5972
7. Extend your demo code to send a partial PATCH request with the help of our package:
6073

6174
```c#
@@ -66,30 +79,43 @@ The easiest way to get started is by using the built-in capabilities of Visual S
6679
Id = "1",
6780
Attributes = new PersonAttributesInPatchRequest
6881
{
69-
FirstName = "Jack"
82+
LastName = "Doe"
7083
}
7184
}
7285
};
7386

74-
// This line results in sending "lastName: null" instead of omitting it.
75-
using (apiClient.RegisterAttributesForRequestDocument<PersonPatchRequestDocument,
76-
PersonAttributesInPatchRequest>(patchRequest, person => person.LastName))
87+
// This line results in sending "firstName: null" instead of omitting it.
88+
using (apiClient.WithPartialAttributeSerialization<PersonPatchRequestDocument, PersonAttributesInPatchRequest>(patchRequest,
89+
person => person.FirstName))
7790
{
78-
PersonPrimaryResponseDocument patchResponse =
79-
await apiClient.PatchPersonAsync("1", patchRequest);
91+
await TranslateAsync(async () => await apiClient.PatchPersonAsync(1, patchRequest));
8092

8193
// The sent request looks like this:
8294
// {
8395
// "data": {
8496
// "type": "people",
8597
// "id": "1",
8698
// "attributes": {
87-
// "firstName": "Jack",
88-
// "lastName": null
99+
// "firstName": null,
100+
// "lastName": "Doe"
89101
// }
90102
// }
91103
// }
92104
}
105+
106+
static async Task<TResponse?> TranslateAsync<TResponse>(Func<Task<TResponse>> operation)
107+
where TResponse : class
108+
{
109+
try
110+
{
111+
return await operation();
112+
}
113+
catch (ApiException exception) when (exception.StatusCode == 204)
114+
{
115+
// Workaround for https://github.com/RicoSuter/NSwag/issues/2499
116+
return null;
117+
}
118+
}
93119
```
94120

95121
### Other IDEs
@@ -100,12 +126,12 @@ Alternatively, the next section shows what to add to your client project file di
100126

101127
```xml
102128
<ItemGroup>
103-
<PackageReference Include="Microsoft.Extensions.ApiDescription.Client" Version="3.0.0">
129+
<PackageReference Include="Microsoft.Extensions.ApiDescription.Client" Version="7.0.11">
104130
<PrivateAssets>all</PrivateAssets>
105131
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
106132
</PackageReference>
107-
<PackageReference Include="Newtonsoft.Json" Version="12.0.2" />
108-
<PackageReference Include="NSwag.ApiDescription.Client" Version="13.0.5">
133+
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
134+
<PackageReference Include="NSwag.ApiDescription.Client" Version="13.20.0">
109135
<PrivateAssets>all</PrivateAssets>
110136
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
111137
</PackageReference>

docs/usage/openapi.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,25 +16,26 @@ JsonApiDotNetCore provides an extension package that enables you to produce an [
1616
```c#
1717
IMvcCoreBuilder mvcCoreBuilder = builder.Services.AddMvcCore();
1818
19+
// Include the mvcBuilder parameter.
1920
builder.Services.AddJsonApi<AppDbContext>(mvcBuilder: mvcCoreBuilder);
2021
21-
// Configures Swashbuckle for JSON:API.
22+
// Configure Swashbuckle for JSON:API.
2223
builder.Services.AddOpenApi(mvcCoreBuilder);
2324
2425
var app = builder.Build();
2526
2627
app.UseRouting();
2728
app.UseJsonApi();
2829
29-
// Adds the Swashbuckle middleware.
30+
// Add the Swashbuckle middleware.
3031
app.UseSwagger();
3132
```
3233
3334
By default, the OpenAPI specification will be available at `http://localhost:<port>/swagger/v1/swagger.json`.
3435
3536
## Documentation
3637
37-
Swashbuckle also ships with [SwaggerUI](https://swagger.io/tools/swagger-ui/), tooling for a generated documentation page. This can be enabled by installing the `Swashbuckle.AspNetCore.SwaggerUI` NuGet package and adding the following to your `Program.cs` file:
38+
Swashbuckle also ships with [SwaggerUI](https://swagger.io/tools/swagger-ui/), which enables to visualize and interact with the API endpoints through a web page. This can be enabled by installing the `Swashbuckle.AspNetCore.SwaggerUI` NuGet package and adding the following to your `Program.cs` file:
3839
3940
```c#
4041
app.UseSwaggerUI();

src/Examples/JsonApiDotNetCoreExampleClient/ExampleApiClient.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
using JsonApiDotNetCore.OpenApi.Client;
33
using Newtonsoft.Json;
44

5+
// ReSharper disable UnusedParameterInPartialMethod
6+
57
namespace JsonApiDotNetCoreExampleClient;
68

79
[UsedImplicitly(ImplicitUseTargetFlags.Itself)]
@@ -11,6 +13,50 @@ partial void UpdateJsonSerializerSettings(JsonSerializerSettings settings)
1113
{
1214
SetSerializerSettingsForJsonApi(settings);
1315

16+
// Optional: Makes the JSON easier to read when logged.
1417
settings.Formatting = Formatting.Indented;
1518
}
19+
20+
// Optional: Log outgoing request to the console.
21+
partial void PrepareRequest(HttpClient client, HttpRequestMessage request, string url)
22+
{
23+
using var _ = new UsingConsoleColor(ConsoleColor.Green);
24+
25+
Console.WriteLine($"--> {request}");
26+
string? requestBody = request.Content?.ReadAsStringAsync().GetAwaiter().GetResult();
27+
28+
if (!string.IsNullOrEmpty(requestBody))
29+
{
30+
Console.WriteLine();
31+
Console.WriteLine(requestBody);
32+
}
33+
}
34+
35+
// Optional: Log incoming response to the console.
36+
partial void ProcessResponse(HttpClient client, HttpResponseMessage response)
37+
{
38+
using var _ = new UsingConsoleColor(ConsoleColor.Cyan);
39+
40+
Console.WriteLine($"<-- {response}");
41+
string responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
42+
43+
if (!string.IsNullOrEmpty(responseBody))
44+
{
45+
Console.WriteLine();
46+
Console.WriteLine(responseBody);
47+
}
48+
}
49+
50+
private sealed class UsingConsoleColor : IDisposable
51+
{
52+
public UsingConsoleColor(ConsoleColor foregroundColor)
53+
{
54+
Console.ForegroundColor = foregroundColor;
55+
}
56+
57+
public void Dispose()
58+
{
59+
Console.ResetColor();
60+
}
61+
}
1662
}

src/Examples/JsonApiDotNetCoreExampleClient/OpenAPIs/swagger.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2051,6 +2051,10 @@
20512051
"additionalProperties": false
20522052
},
20532053
"personAttributesInResponse": {
2054+
"required": [
2055+
"displayName",
2056+
"lastName"
2057+
],
20542058
"type": "object",
20552059
"properties": {
20562060
"firstName": {
@@ -2341,6 +2345,9 @@
23412345
"additionalProperties": false
23422346
},
23432347
"tagAttributesInResponse": {
2348+
"required": [
2349+
"name"
2350+
],
23442351
"type": "object",
23452352
"properties": {
23462353
"name": {
@@ -2715,6 +2722,10 @@
27152722
"additionalProperties": false
27162723
},
27172724
"todoItemAttributesInResponse": {
2725+
"required": [
2726+
"description",
2727+
"priority"
2728+
],
27182729
"type": "object",
27192730
"properties": {
27202731
"description": {
@@ -2970,6 +2981,9 @@
29702981
"additionalProperties": false
29712982
},
29722983
"todoItemRelationshipsInResponse": {
2984+
"required": [
2985+
"owner"
2986+
],
29732987
"type": "object",
29742988
"properties": {
29752989
"owner": {
Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,47 @@
1-
namespace JsonApiDotNetCoreExampleClient;
1+
using JsonApiDotNetCoreExampleClient;
22

3-
internal static class Program
4-
{
5-
private const string BaseUrl = "http://localhost:14140";
3+
using var httpClient = new HttpClient();
4+
var apiClient = new ExampleApiClient("http://localhost:14140", httpClient);
65

7-
private static async Task Main()
8-
{
9-
using var httpClient = new HttpClient();
6+
PersonCollectionResponseDocument getResponse = await apiClient.GetPersonCollectionAsync();
107

11-
ExampleApiClient exampleApiClient = new(BaseUrl, httpClient);
8+
foreach (PersonDataInResponse person in getResponse.Data)
9+
{
10+
Console.WriteLine($"Found person {person.Id}: {person.Attributes.DisplayName}");
11+
}
1212

13-
try
14-
{
15-
const int nonExistingId = int.MaxValue;
16-
await exampleApiClient.DeletePersonAsync(nonExistingId);
17-
}
18-
catch (ApiException exception)
13+
var patchRequest = new PersonPatchRequestDocument
14+
{
15+
Data = new PersonDataInPatchRequest
16+
{
17+
Id = "1",
18+
Attributes = new PersonAttributesInPatchRequest
1919
{
20-
Console.WriteLine(exception.Response);
20+
LastName = "Doe"
2121
}
22+
}
23+
};
2224

23-
Console.WriteLine("Press any key to close.");
24-
Console.ReadKey();
25+
// This line results in sending "firstName: null" instead of omitting it.
26+
using (apiClient.WithPartialAttributeSerialization<PersonPatchRequestDocument, PersonAttributesInPatchRequest>(patchRequest, person => person.FirstName))
27+
{
28+
await TranslateAsync(async () => await apiClient.PatchPersonAsync(1, patchRequest));
29+
}
30+
31+
Console.WriteLine("Press any key to close.");
32+
Console.ReadKey();
33+
34+
// ReSharper disable once UnusedLocalFunctionReturnValue
35+
static async Task<TResponse?> TranslateAsync<TResponse>(Func<Task<TResponse>> operation)
36+
where TResponse : class
37+
{
38+
try
39+
{
40+
return await operation();
41+
}
42+
catch (ApiException exception) when (exception.StatusCode == 204)
43+
{
44+
// Workaround for https://github.com/RicoSuter/NSwag/issues/2499
45+
return null;
2546
}
2647
}

src/JsonApiDotNetCore.OpenApi.Client/Exceptions/ApiException.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ public sealed class ApiException : Exception
1414

1515
public IReadOnlyDictionary<string, IEnumerable<string>> Headers { get; }
1616

17-
public ApiException(string message, int statusCode, string? response, IReadOnlyDictionary<string, IEnumerable<string>> headers, Exception innerException)
17+
public ApiException(string message, int statusCode, string? response, IReadOnlyDictionary<string, IEnumerable<string>> headers, Exception? innerException)
1818
: base($"{message}\n\nStatus: {statusCode}\nResponse: \n{response ?? "(null)"}", innerException)
1919
{
2020
StatusCode = statusCode;

src/JsonApiDotNetCore.OpenApi.Client/IJsonApiClient.cs

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,19 @@ namespace JsonApiDotNetCore.OpenApi.Client;
55
public interface IJsonApiClient
66
{
77
/// <summary>
8-
/// Ensures correct serialization of attributes in a POST/PATCH Resource request body. In JSON:API, an omitted attribute indicates to ignore it, while an
9-
/// attribute that is set to "null" means to clear it. This poses a problem because the serializer cannot distinguish between "you have explicitly set
10-
/// this .NET property to null" vs "you didn't touch it, so it is null by default" when converting an instance to JSON. Therefore, calling this method
11-
/// treats all attributes that contain their default value (<c>null</c> for reference types, <c>0</c> for integers, <c>false</c> for booleans, etc) as
12-
/// omitted unless explicitly listed to include them using <paramref name="alwaysIncludedAttributeSelectors" />.
8+
/// Ensures correct serialization of JSON:API attributes in the request body of a POST/PATCH request at a resource endpoint. Properties with default
9+
/// values are omitted, unless explicitly included using <paramref name="alwaysIncludedAttributeSelectors" />
10+
/// <para>
11+
/// In JSON:API, an omitted attribute indicates to ignore it, while an attribute that is set to <c>null</c> means to clear it. This poses a problem,
12+
/// because the serializer cannot distinguish between "you have explicitly set this .NET property to its default value" vs "you didn't touch it, so it
13+
/// contains its default value" when converting to JSON.
14+
/// </para>
1315
/// </summary>
1416
/// <param name="requestDocument">
15-
/// The request document instance for which this registration applies.
17+
/// The request document instance for which default values should be omitted.
1618
/// </param>
1719
/// <param name="alwaysIncludedAttributeSelectors">
18-
/// Optional. A list of expressions to indicate which properties to unconditionally include in the JSON request body. For example:
20+
/// Optional. A list of lambda expressions that indicate which properties to always include in the JSON request body. For example:
1921
/// <code><![CDATA[
2022
/// video => video.Title, video => video.Summary
2123
/// ]]></code>
@@ -28,9 +30,10 @@ public interface IJsonApiClient
2830
/// </typeparam>
2931
/// <returns>
3032
/// An <see cref="IDisposable" /> to clear the current registration. For efficient memory usage, it is recommended to wrap calls to this method in a
31-
/// <c>using</c> statement, so the registrations are cleaned up after executing the request.
33+
/// <c>using</c> statement, so the registrations are cleaned up after executing the request. After disposal, the client can be reused without the
34+
/// registrations added earlier.
3235
/// </returns>
33-
IDisposable RegisterAttributesForRequestDocument<TRequestDocument, TAttributesObject>(TRequestDocument requestDocument,
36+
IDisposable WithPartialAttributeSerialization<TRequestDocument, TAttributesObject>(TRequestDocument requestDocument,
3437
params Expression<Func<TAttributesObject, object?>>[] alwaysIncludedAttributeSelectors)
3538
where TRequestDocument : class;
3639
}

0 commit comments

Comments
 (0)