Skip to content

Support NRT and MSV in required and nullable #1185

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 76 commits into from
Oct 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
76 commits
Select commit Hold shift + click to select a range
99dcfa8
Added tests for nullable and required properties in schema generation
maurei Sep 5, 2022
2604f84
Added handling of modelstate validation in setting required attributes
maurei Jul 5, 2022
f7194d9
Not all swagger documents need to be saved to disk; changes in OpenAp…
maurei Sep 5, 2022
449a079
Added OpenApi client tests for nullable and required properties
maurei Sep 6, 2022
c55a2cb
Use NullabilityInfoContext for nullability information
maurei Sep 6, 2022
31638b8
Merge branch 'merge-master-v503-into-openapi' into openapi-required-a…
bkoelman Sep 8, 2022
fc15912
Post-merge fixes
bkoelman Sep 8, 2022
7288b8a
Merge branch 'openapi' into openapi-required-and-nullable-properties
bkoelman Dec 15, 2022
d7c8b4b
Post-merge fixes
bkoelman Dec 15, 2022
d72e43e
Fixed: do not share NullabilityInfoContext, it is not thread-safe
bkoelman Dec 15, 2022
526d03f
Merge pull request #1229 from json-api-dotnet/merge-openapi-into-1185
bkoelman Dec 15, 2022
f8e640b
Review feedback
maurei Dec 19, 2022
7266bc6
remove redundant new lines in eof added by cleanupcode
maurei Dec 19, 2022
945a803
Improved naming in OpenApiTests/SchemaProperties
maurei Dec 19, 2022
f5bb7fc
Review feedback: NullabilityTests
maurei Dec 20, 2022
919a3f8
Improved JsonApiClient and testing
maurei Dec 20, 2022
87aea3e
Fix test: should not omit required field in test request body
maurei Dec 20, 2022
2ff06fc
Temp enable CI buid for current branch
maurei Dec 20, 2022
932367e
Rename test files: it no longer only concerns required attributes, bu…
maurei Dec 20, 2022
061f426
Changes and tests for support of nullable and required for relationships
maurei Dec 22, 2022
90970a8
- Rename placeholder model names and properties to examples consisent…
maurei Dec 24, 2022
affc187
Move into consistent folder structure, remove bad cleanupcode eof lin…
maurei Dec 24, 2022
daf25a2
Merge branch 'openapi' into openapi-required-and-nullable-properties
maurei Dec 24, 2022
d4f1d69
Merge pull request #1231 from json-api-dotnet/attributes-object-schem…
maurei Dec 27, 2022
76e098a
Organise tests such that they map directly to the tables in #1231 and…
maurei Dec 28, 2022
a2e7a96
Add two missing 'Act' comments
maurei Dec 28, 2022
1815dec
More elaborate testing
maurei Jan 3, 2023
63ba43d
Remove non-sensical testcases. Add caching in ObjectExtensions.
maurei Jan 4, 2023
3e9708a
Remove overlooked code duplication in OpenApiTests, revert reflection…
maurei Jan 5, 2023
8304c05
Make AutoFakers deterministic; generate positive IDs
bkoelman Jan 7, 2023
9672b14
Fix nameof
bkoelman Jan 7, 2023
345a689
Use On/Off naming, shorten type names by using Nrt+Msv
bkoelman Jan 7, 2023
22ad300
Renamed EmptyResource to Empty to further shorten FK names
bkoelman Jan 7, 2023
3069533
Fixed invalid EF Core mappings, resulting in logged warnings and inab…
bkoelman Jan 7, 2023
36ac8e7
Move misplaced Act comments
bkoelman Jan 7, 2023
a88bce8
Optimize and clarify ResourceFieldValidationMetadataProvider
bkoelman Jan 8, 2023
2652063
Rename method, provide error message
bkoelman Jan 8, 2023
f2ecb5f
Refactor JsonApiClient: simplified recursion by using two converters,…
bkoelman Jan 8, 2023
d337230
Add relationship nullability assertions in OpenAPI client tests
bkoelman Jan 8, 2023
52cc1f1
Cleanup JsonElementExtensions
bkoelman Jan 8, 2023
b30a680
Cleanup ObjectExtensions
bkoelman Jan 8, 2023
73c3232
Make base type abstract, remove redundant TranslateAsync calls, inlin…
bkoelman Jan 8, 2023
55e702c
Simplify usings
bkoelman Jan 9, 2023
28bb39b
Sync up test names
bkoelman Jan 9, 2023
ff4262e
Fix invalid tests
bkoelman Jan 9, 2023
821d09d
Fix assertion messages
bkoelman Jan 9, 2023
5b71d90
Sync up tests
bkoelman Jan 9, 2023
df9a1b4
Revert change to pass full options instead of just the naming policy
bkoelman Jan 9, 2023
9844e6a
Fix casing in test names
bkoelman Jan 10, 2023
6071f5b
Simplify Cannot_exclude_Id tests
bkoelman Jan 10, 2023
6881208
Rename base type to avoid OpenApiClientTests.OpenApiClientTests
bkoelman Jan 10, 2023
cc3815c
Adapt to existing naming convention
bkoelman Sep 30, 2023
4ef8214
Remove redundant assertions, fix formatting
bkoelman Sep 30, 2023
dcdf5a5
Correct test names
bkoelman Sep 30, 2023
2d7f8b9
Centralize code for property assignment in tests
bkoelman Oct 1, 2023
7364e94
Merge pull request #1244 from json-api-dotnet/openapi-nrt-msv-updates
bkoelman Oct 1, 2023
7c6f9eb
Merge branch 'openapi' into openapi-required-and-nullable-properties
bkoelman Oct 1, 2023
41d6d67
Apply Resharper hint: convert switch statement to expression
bkoelman Oct 1, 2023
bd31216
Simplify expressions
bkoelman Oct 1, 2023
6a87751
Simplify exception assertions
bkoelman Oct 1, 2023
5c85c3a
Use string interpolation
bkoelman Oct 1, 2023
b08ab9b
Corrections in openapi documentation
bkoelman Oct 1, 2023
f7786bb
Simplify code
bkoelman Oct 1, 2023
c3c4844
Remove redundant suppression
bkoelman Oct 1, 2023
66a2dc4
Merge branch 'master' into openapi-required-and-nullable-properties
bkoelman Oct 1, 2023
f46db8a
Combine OpenAPI client tests for create resource with null/default at…
bkoelman Oct 1, 2023
63bf071
Fixup OpenAPI example and docs
bkoelman Oct 1, 2023
ce8045d
Revert "Merge branch 'master' into openapi-required-and-nullable-prop…
bkoelman Oct 1, 2023
fac0471
Workaround for running OpenAPI tests on Windows
bkoelman Oct 2, 2023
dd667be
Merge branch 'openapi' into openapi-required-and-nullable-properties
bkoelman Oct 2, 2023
9194487
Address failing InspectCode
bkoelman Oct 2, 2023
85d608b
Remove redundant calls
bkoelman Oct 2, 2023
007ff30
Remove redundant tests
bkoelman Oct 2, 2023
817aeb3
Move types out of the wrong namespace
bkoelman Oct 2, 2023
fb9b12a
Merge branch 'openapi' into openapi-required-and-nullable-properties
bkoelman Oct 4, 2023
0dd49b0
Remove redundant suppressions in openapi after update to CSharpGuidel…
bkoelman Oct 4, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 52 additions & 26 deletions docs/usage/openapi-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,46 +2,56 @@

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

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".
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 concern 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".

## Getting started

### Visual Studio

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.
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.

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

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

Tip: To later re-download swagger.json and regenerate the client code, right-click **Dependencies** > **Manage Connected Services** and click the **Refresh** icon.
3. Although not strictly required, we recommend to run package update now, which fixes some issues and removes the `Stream` parameter from generated calls.
> [!TIP]
> To later re-download swagger.json and regenerate the client code,
> right-click **Dependencies** > **Manage Connected Services** and click the **Refresh** icon.

4. Add some demo code that calls one of your JSON:API endpoints. For example:
3. Although not strictly required, we recommend to run package update now, which fixes some issues.

4. Add code that calls one of your JSON:API endpoints.

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

PersonCollectionResponseDocument getResponse =
await apiClient.GetPersonCollectionAsync();
PersonCollectionResponseDocument getResponse = await apiClient.GetPersonCollectionAsync();

foreach (PersonDataInResponse person in getResponse.Data)
{
Console.WriteLine($"Found user {person.Id} named " +
$"'{person.Attributes.FirstName} {person.Attributes.LastName}'.");
Console.WriteLine($"Found person {person.Id}: {person.Attributes.DisplayName}");
}
```

5. Add our client package to your project:

```
dotnet add package JsonApiDotNetCore.OpenApi.Client
```
```
dotnet add package JsonApiDotNetCore.OpenApi.Client
```

6. Add the following glue code to connect our package with your generated code.

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.
> [!NOTE]
> The class name must be the same as specified in step 2.
> If you also specified a namespace, put this class in the same namespace.
> For example, add `namespace GeneratedCode;` below the `using` lines.

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

> [!TIP]
> The project at src/Examples/JsonApiDotNetCoreExampleClient contains an enhanced version that logs the HTTP requests and responses.

7. Extend your demo code to send a partial PATCH request with the help of our package:

```c#
Expand All @@ -66,30 +79,43 @@ The easiest way to get started is by using the built-in capabilities of Visual S
Id = "1",
Attributes = new PersonAttributesInPatchRequest
{
FirstName = "Jack"
LastName = "Doe"
}
}
};

// This line results in sending "lastName: null" instead of omitting it.
using (apiClient.RegisterAttributesForRequestDocument<PersonPatchRequestDocument,
PersonAttributesInPatchRequest>(patchRequest, person => person.LastName))
// This line results in sending "firstName: null" instead of omitting it.
using (apiClient.WithPartialAttributeSerialization<PersonPatchRequestDocument, PersonAttributesInPatchRequest>(patchRequest,
person => person.FirstName))
{
PersonPrimaryResponseDocument patchResponse =
await apiClient.PatchPersonAsync("1", patchRequest);
await TranslateAsync(async () => await apiClient.PatchPersonAsync(1, patchRequest));

// The sent request looks like this:
// {
// "data": {
// "type": "people",
// "id": "1",
// "attributes": {
// "firstName": "Jack",
// "lastName": null
// "firstName": null,
// "lastName": "Doe"
// }
// }
// }
}

static async Task<TResponse?> TranslateAsync<TResponse>(Func<Task<TResponse>> operation)
where TResponse : class
{
try
{
return await operation();
}
catch (ApiException exception) when (exception.StatusCode == 204)
{
// Workaround for https://github.com/RicoSuter/NSwag/issues/2499
return null;
}
}
```

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

```xml
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.ApiDescription.Client" Version="3.0.0">
<PackageReference Include="Microsoft.Extensions.ApiDescription.Client" Version="7.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json" Version="12.0.2" />
<PackageReference Include="NSwag.ApiDescription.Client" Version="13.0.5">
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NSwag.ApiDescription.Client" Version="13.20.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
Expand Down
7 changes: 4 additions & 3 deletions docs/usage/openapi.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,25 +16,26 @@ JsonApiDotNetCore provides an extension package that enables you to produce an [
```c#
IMvcCoreBuilder mvcCoreBuilder = builder.Services.AddMvcCore();

// Include the mvcBuilder parameter.
builder.Services.AddJsonApi<AppDbContext>(mvcBuilder: mvcCoreBuilder);

// Configures Swashbuckle for JSON:API.
// Configure Swashbuckle for JSON:API.
builder.Services.AddOpenApi(mvcCoreBuilder);

var app = builder.Build();

app.UseRouting();
app.UseJsonApi();

// Adds the Swashbuckle middleware.
// Add the Swashbuckle middleware.
app.UseSwagger();
```

By default, the OpenAPI specification will be available at `http://localhost:<port>/swagger/v1/swagger.json`.

## Documentation

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:
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:

```c#
app.UseSwaggerUI();
Expand Down
46 changes: 46 additions & 0 deletions src/Examples/JsonApiDotNetCoreExampleClient/ExampleApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
using JsonApiDotNetCore.OpenApi.Client;
using Newtonsoft.Json;

// ReSharper disable UnusedParameterInPartialMethod

namespace JsonApiDotNetCoreExampleClient;

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

// Optional: Makes the JSON easier to read when logged.
settings.Formatting = Formatting.Indented;
}

// Optional: Log outgoing request to the console.
partial void PrepareRequest(HttpClient client, HttpRequestMessage request, string url)
{
using var _ = new UsingConsoleColor(ConsoleColor.Green);

Console.WriteLine($"--> {request}");
string? requestBody = request.Content?.ReadAsStringAsync().GetAwaiter().GetResult();

if (!string.IsNullOrEmpty(requestBody))
{
Console.WriteLine();
Console.WriteLine(requestBody);
}
}

// Optional: Log incoming response to the console.
partial void ProcessResponse(HttpClient client, HttpResponseMessage response)
{
using var _ = new UsingConsoleColor(ConsoleColor.Cyan);

Console.WriteLine($"<-- {response}");
string responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();

if (!string.IsNullOrEmpty(responseBody))
{
Console.WriteLine();
Console.WriteLine(responseBody);
}
}

private sealed class UsingConsoleColor : IDisposable
{
public UsingConsoleColor(ConsoleColor foregroundColor)
{
Console.ForegroundColor = foregroundColor;
}

public void Dispose()
{
Console.ResetColor();
}
}
}
14 changes: 14 additions & 0 deletions src/Examples/JsonApiDotNetCoreExampleClient/OpenAPIs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -2051,6 +2051,10 @@
"additionalProperties": false
},
"personAttributesInResponse": {
"required": [
"displayName",
"lastName"
],
"type": "object",
"properties": {
"firstName": {
Expand Down Expand Up @@ -2341,6 +2345,9 @@
"additionalProperties": false
},
"tagAttributesInResponse": {
"required": [
"name"
],
"type": "object",
"properties": {
"name": {
Expand Down Expand Up @@ -2715,6 +2722,10 @@
"additionalProperties": false
},
"todoItemAttributesInResponse": {
"required": [
"description",
"priority"
],
"type": "object",
"properties": {
"description": {
Expand Down Expand Up @@ -2970,6 +2981,9 @@
"additionalProperties": false
},
"todoItemRelationshipsInResponse": {
"required": [
"owner"
],
"type": "object",
"properties": {
"owner": {
Expand Down
55 changes: 38 additions & 17 deletions src/Examples/JsonApiDotNetCoreExampleClient/Program.cs
Original file line number Diff line number Diff line change
@@ -1,26 +1,47 @@
namespace JsonApiDotNetCoreExampleClient;
using JsonApiDotNetCoreExampleClient;

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

private static async Task Main()
{
using var httpClient = new HttpClient();
PersonCollectionResponseDocument getResponse = await apiClient.GetPersonCollectionAsync();

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

try
{
const int nonExistingId = int.MaxValue;
await exampleApiClient.DeletePersonAsync(nonExistingId);
}
catch (ApiException exception)
var patchRequest = new PersonPatchRequestDocument
{
Data = new PersonDataInPatchRequest
{
Id = "1",
Attributes = new PersonAttributesInPatchRequest
{
Console.WriteLine(exception.Response);
LastName = "Doe"
}
}
};

Console.WriteLine("Press any key to close.");
Console.ReadKey();
// This line results in sending "firstName: null" instead of omitting it.
using (apiClient.WithPartialAttributeSerialization<PersonPatchRequestDocument, PersonAttributesInPatchRequest>(patchRequest, person => person.FirstName))
{
await TranslateAsync(async () => await apiClient.PatchPersonAsync(1, patchRequest));
}

Console.WriteLine("Press any key to close.");
Console.ReadKey();

// ReSharper disable once UnusedLocalFunctionReturnValue
static async Task<TResponse?> TranslateAsync<TResponse>(Func<Task<TResponse>> operation)
where TResponse : class
{
try
{
return await operation();
}
catch (ApiException exception) when (exception.StatusCode == 204)
{
// Workaround for https://github.com/RicoSuter/NSwag/issues/2499
return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public sealed class ApiException : Exception

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

public ApiException(string message, int statusCode, string? response, IReadOnlyDictionary<string, IEnumerable<string>> headers, Exception innerException)
public ApiException(string message, int statusCode, string? response, IReadOnlyDictionary<string, IEnumerable<string>> headers, Exception? innerException)
: base($"{message}\n\nStatus: {statusCode}\nResponse: \n{response ?? "(null)"}", innerException)
{
StatusCode = statusCode;
Expand Down
21 changes: 12 additions & 9 deletions src/JsonApiDotNetCore.OpenApi.Client/IJsonApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,19 @@ namespace JsonApiDotNetCore.OpenApi.Client;
public interface IJsonApiClient
{
/// <summary>
/// Ensures correct serialization of attributes in a POST/PATCH Resource request body. In JSON:API, an omitted attribute indicates to ignore it, while an
/// attribute that is set to "null" means to clear it. This poses a problem because the serializer cannot distinguish between "you have explicitly set
/// 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
/// 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
/// omitted unless explicitly listed to include them using <paramref name="alwaysIncludedAttributeSelectors" />.
/// Ensures correct serialization of JSON:API attributes in the request body of a POST/PATCH request at a resource endpoint. Properties with default
/// values are omitted, unless explicitly included using <paramref name="alwaysIncludedAttributeSelectors" />
/// <para>
/// 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,
/// 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
/// contains its default value" when converting to JSON.
/// </para>
/// </summary>
/// <param name="requestDocument">
/// The request document instance for which this registration applies.
/// The request document instance for which default values should be omitted.
/// </param>
/// <param name="alwaysIncludedAttributeSelectors">
/// Optional. A list of expressions to indicate which properties to unconditionally include in the JSON request body. For example:
/// Optional. A list of lambda expressions that indicate which properties to always include in the JSON request body. For example:
/// <code><![CDATA[
/// video => video.Title, video => video.Summary
/// ]]></code>
Expand All @@ -28,9 +30,10 @@ public interface IJsonApiClient
/// </typeparam>
/// <returns>
/// An <see cref="IDisposable" /> to clear the current registration. For efficient memory usage, it is recommended to wrap calls to this method in a
/// <c>using</c> statement, so the registrations are cleaned up after executing the request.
/// <c>using</c> statement, so the registrations are cleaned up after executing the request. After disposal, the client can be reused without the
/// registrations added earlier.
/// </returns>
IDisposable RegisterAttributesForRequestDocument<TRequestDocument, TAttributesObject>(TRequestDocument requestDocument,
IDisposable WithPartialAttributeSerialization<TRequestDocument, TAttributesObject>(TRequestDocument requestDocument,
params Expression<Func<TAttributesObject, object?>>[] alwaysIncludedAttributeSelectors)
where TRequestDocument : class;
}
Loading