Skip to content

Replace API for change tracking with NSwag to support atomic operations #1684

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 1 commit into from
Feb 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
87 changes: 32 additions & 55 deletions docs/usage/openapi-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ After [enabling OpenAPI](~/usage/openapi.md), you can generate a typed JSON:API
> [client libraries](https://jsonapi.org/implementations/#client-libraries).

The following code generators are supported, though you may try others as well:
- [NSwag](https://github.com/RicoSuter/NSwag) (v14.1 or higher): Produces clients for C# and TypeScript
- [NSwag](https://github.com/RicoSuter/NSwag) (v14.1 or higher): Produces clients for C# (requires `Newtonsoft.Json`) and TypeScript
- [Kiota](https://learn.microsoft.com/en-us/openapi/kiota/overview): Produces clients for C#, Go, Java, PHP, Python, Ruby, Swift and TypeScript

# [NSwag](#tab/nswag)
Expand All @@ -21,7 +21,7 @@ dotnet add package JsonApiDotNetCore.OpenApi.Client.NSwag

# [Kiota](#tab/kiota)

For C# clients, we provide an additional package that provides workarounds for bugs in Kiota.
For C# clients, we provide an additional package that provides workarounds for bugs in Kiota, as well as MSBuild integration.

To add it to your project, run the following command:
```
Expand Down Expand Up @@ -60,27 +60,6 @@ The following steps describe how to generate and use a JSON:API client in C#, co
dotnet add package JsonApiDotNetCore.OpenApi.Client.NSwag
```

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

> [!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.NSwag;
using Newtonsoft.Json;

partial class ExampleApiClient : JsonApiClient
{
partial void Initialize()
{
_instanceSettings = new JsonSerializerSettings(_settings.Value);
SetSerializerSettingsForJsonApi(_instanceSettings);
}
}
```

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

```c#
Expand Down Expand Up @@ -108,33 +87,32 @@ The following steps describe how to generate and use a JSON:API client in C#, co
Data = new DataInUpdatePersonRequest
{
Id = "1",
Attributes = new AttributesInUpdatePersonRequest
// Using TrackChangesFor to send "firstName: null" instead of omitting it.
Attributes = new TrackChangesFor<AttributesInUpdatePersonRequest>(_apiClient)
{
LastName = "Doe"
}
Initializer =
{
FirstName = null,
LastName = "Doe"
}
}.Initializer
}
};

// This line results in sending "firstName: null" instead of omitting it.
using (apiClient.WithPartialAttributeSerialization<UpdatePersonRequestDocument, AttributesInUpdatePersonRequest>(
updatePersonRequest, person => person.FirstName))
{
// Workaround for https://github.com/RicoSuter/NSwag/issues/2499.
await ApiResponse.TranslateAsync(() =>
apiClient.PatchPersonAsync(updatePersonRequest.Data.Id, updatePersonRequest));

// The sent request looks like this:
// {
// "data": {
// "type": "people",
// "id": "1",
// "attributes": {
// "firstName": null,
// "lastName": "Doe"
// }
// }
// }
}
await ApiResponse.TranslateAsync(async () =>
await _apiClient.PatchPersonAsync(updatePersonRequest.Data.Id, updatePersonRequest));

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

> [!TIP]
Expand All @@ -146,9 +124,7 @@ The following steps describe how to generate and use a JSON:API client in C#, co

### Other IDEs

When using the command line, you can try the [Microsoft.dotnet-openapi Global Tool](https://docs.microsoft.com/en-us/aspnet/core/web-api/microsoft.dotnet-openapi?view=aspnetcore-5.0).

Alternatively, the following section shows what to add to your client project file directly:
The following section shows what to add to your client project file directly:

```xml
<ItemGroup>
Expand All @@ -160,9 +136,8 @@ Alternatively, the following section shows what to add to your client project fi
<ItemGroup>
<OpenApiReference Include="OpenAPIs\swagger.json">
<SourceUri>http://localhost:14140/swagger/v1/swagger.json</SourceUri>
<CodeGenerator>NSwagCSharp</CodeGenerator>
<ClassName>ExampleApiClient</ClassName>
<OutputPath>ExampleApiClient.cs</OutputPath>
<OutputPath>%(ClassName).cs</OutputPath>
</OpenApiReference>
</ItemGroup>
```
Expand Down Expand Up @@ -193,20 +168,20 @@ Various switches enable you to tweak the client generation to your needs. See th

# [NSwag](#tab/nswag)

The `OpenApiReference` can be customized using various [NSwag-specific MSBuild properties](https://github.com/RicoSuter/NSwag/blob/7d6df3af95081f3f0ed6dee04be8d27faa86f91a/src/NSwag.ApiDescription.Client/NSwag.ApiDescription.Client.props).
The `OpenApiReference` element can be customized using various [NSwag-specific MSBuild properties](https://github.com/RicoSuter/NSwag/blob/7d6df3af95081f3f0ed6dee04be8d27faa86f91a/src/NSwag.ApiDescription.Client/NSwag.ApiDescription.Client.props).
See [the source code](https://github.com/RicoSuter/NSwag/blob/master/src/NSwag.Commands/Commands/CodeGeneration/OpenApiToCSharpClientCommand.cs) for their meaning.
The `JsonApiDotNetCore.OpenApi.Client.NSwag` package sets various of these for optimal JSON:API support.

> [!NOTE]
> Earlier versions of NSwag required the use of `<Options>` to specify command-line switches directly.
> This is no longer recommended and may conflict with the new MSBuild properties.

For example, the following section puts the generated code in a namespace and generates an interface (handy when writing tests):
For example, the following section puts the generated code in a namespace, makes the client class internal and generates an interface (handy when writing tests):

```xml
<OpenApiReference Include="swagger.json">
<Namespace>ExampleProject.GeneratedCode</Namespace>
<ClassName>SalesApiClient</ClassName>
<CodeGenerator>NSwagCSharp</CodeGenerator>
<NSwagClientClassAccessModifier>internal</NSwagClientClassAccessModifier>
<NSwagGenerateClientInterfaces>true</NSwagGenerateClientInterfaces>
</OpenApiReference>
```
Expand Down Expand Up @@ -306,6 +281,7 @@ The [example project](https://github.com/json-api-dotnet/JsonApiDotNetCore/tree/
demonstrates how to use them. It uses local IDs to:
- Create a new tag
- Create a new person
- Update the person to clear an attribute (using `TrackChangesFor`)
- Create a new todo-item, tagged with the new tag, and owned by the new person
- Assign the todo-item to the created person

Expand All @@ -316,6 +292,7 @@ See the [example project](https://github.com/json-api-dotnet/JsonApiDotNetCore/t
demonstrates how to use them. It uses local IDs to:
- Create a new tag
- Create a new person
- Update the person to clear an attribute (using built-in backing-store)
- Create a new todo-item, tagged with the new tag, and owned by the new person
- Assign the todo-item to the created person

Expand Down
23 changes: 19 additions & 4 deletions src/Examples/OpenApiKiotaClientExample/Worker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)

await SendOperationsRequestAsync(stoppingToken);

_ = await _apiClient.Api.People["999999"].GetAsync(cancellationToken: stoppingToken);
await _apiClient.Api.People["999999"].GetAsync(cancellationToken: stoppingToken);
}
catch (ErrorResponseDocument exception)
{
Expand Down Expand Up @@ -100,7 +100,7 @@ private async Task UpdatePersonAsync(CancellationToken cancellationToken)
}
};

_ = await _apiClient.Api.People[updatePersonRequest.Data.Id].PatchAsync(updatePersonRequest, cancellationToken: cancellationToken);
await _apiClient.Api.People[updatePersonRequest.Data.Id].PatchAsync(updatePersonRequest, cancellationToken: cancellationToken);
}

private async Task SendOperationsRequestAsync(CancellationToken cancellationToken)
Expand Down Expand Up @@ -131,7 +131,22 @@ private async Task SendOperationsRequestAsync(CancellationToken cancellationToke
Lid = "new-person",
Attributes = new AttributesInCreatePersonRequest
{
LastName = "Cinderella"
FirstName = "Cinderella",
LastName = "Tremaine"
}
}
},
new UpdatePersonOperation
{
Op = UpdateOperationCode.Update,
Data = new DataInUpdatePersonRequest
{
Type = PersonResourceType.People,
Lid = "new-person",
Attributes = new AttributesInUpdatePersonRequest
{
// The --backing-store switch enables to send null and default values.
FirstName = null
}
}
},
Expand Down Expand Up @@ -191,7 +206,7 @@ private async Task SendOperationsRequestAsync(CancellationToken cancellationToke

OperationsResponseDocument? operationsResponse = await _apiClient.Api.Operations.PostAsync(operationsRequest, cancellationToken: cancellationToken);

var newTodoItem = (TodoItemDataInResponse)operationsResponse!.AtomicResults!.ElementAt(2).Data!;
var newTodoItem = (TodoItemDataInResponse)operationsResponse!.AtomicResults!.ElementAt(3).Data!;
Console.WriteLine($"Created todo-item with ID {newTodoItem.Id}: {newTodoItem.Attributes!.Description}.");
}
}
20 changes: 0 additions & 20 deletions src/Examples/OpenApiNSwagClientExample/ExampleApiClient.cs

This file was deleted.

42 changes: 29 additions & 13 deletions src/Examples/OpenApiNSwagClientExample/Worker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)

await SendOperationsRequestAsync(stoppingToken);

_ = await _apiClient.GetPersonAsync("999999", null, null, stoppingToken);
await _apiClient.GetPersonAsync("999999", null, null, stoppingToken);
}
catch (ApiException<ErrorResponseDocument> exception)
{
Expand All @@ -57,20 +57,20 @@ private async Task UpdatePersonAsync(CancellationToken cancellationToken)
Data = new DataInUpdatePersonRequest
{
Id = "1",
Attributes = new AttributesInUpdatePersonRequest
// Using TrackChangesFor to send "firstName: null" instead of omitting it.
Attributes = new TrackChangesFor<AttributesInUpdatePersonRequest>(_apiClient)
{
LastName = "Doe"
}
Initializer =
{
FirstName = null,
LastName = "Doe"
}
}.Initializer
}
};

// This line results in sending "firstName: null" instead of omitting it.
using (_apiClient.WithPartialAttributeSerialization<UpdatePersonRequestDocument, AttributesInUpdatePersonRequest>(updatePersonRequest,
person => person.FirstName))
{
_ = await ApiResponse.TranslateAsync(async () =>
await _apiClient.PatchPersonAsync(updatePersonRequest.Data.Id, updatePersonRequest, cancellationToken: cancellationToken));
}
await ApiResponse.TranslateAsync(async () =>
await _apiClient.PatchPersonAsync(updatePersonRequest.Data.Id, updatePersonRequest, cancellationToken: cancellationToken));
}

private async Task SendOperationsRequestAsync(CancellationToken cancellationToken)
Expand All @@ -97,10 +97,26 @@ private async Task SendOperationsRequestAsync(CancellationToken cancellationToke
Lid = "new-person",
Attributes = new AttributesInCreatePersonRequest
{
LastName = "Cinderella"
FirstName = "Cinderella",
LastName = "Tremaine"
}
}
},
new UpdatePersonOperation
{
Data = new DataInUpdatePersonRequest
{
Lid = "new-person",
// Using TrackChangesFor to send "firstName: null" instead of omitting it.
Attributes = new TrackChangesFor<AttributesInUpdatePersonRequest>(_apiClient)
{
Initializer =
{
FirstName = null
}
}.Initializer
}
},
new CreateTodoItemOperation
{
Data = new DataInCreateTodoItemRequest
Expand Down Expand Up @@ -149,7 +165,7 @@ private async Task SendOperationsRequestAsync(CancellationToken cancellationToke

ApiResponse<OperationsResponseDocument> operationsResponse = await _apiClient.PostOperationsAsync(operationsRequest, cancellationToken);

var newTodoItem = (TodoItemDataInResponse)operationsResponse.Result.Atomic_results.ElementAt(2).Data!;
var newTodoItem = (TodoItemDataInResponse)operationsResponse.Result.Atomic_results.ElementAt(3).Data!;
Console.WriteLine($"Created todo-item with ID {newTodoItem.Id}: {newTodoItem.Attributes!.Description}.");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using JetBrains.Annotations;
using Newtonsoft.Json;

namespace JsonApiDotNetCore.OpenApi.Client.NSwag;

// Referenced from liquid template, to ensure the built-in JsonInheritanceConverter from NSwag is never used.
[PublicAPI]
public abstract class BlockedJsonInheritanceConverter : JsonConverter
{
private const string DefaultDiscriminatorName = "discriminator";

public string DiscriminatorName { get; }

public override bool CanWrite => true;
public override bool CanRead => true;

protected BlockedJsonInheritanceConverter()
: this(DefaultDiscriminatorName)
{
}

protected BlockedJsonInheritanceConverter(string discriminatorName)
{
ArgumentException.ThrowIfNullOrEmpty(discriminatorName);

DiscriminatorName = discriminatorName;
}

public override bool CanConvert(Type objectType)
{
return true;
}

public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{
throw new InvalidOperationException("JsonInheritanceConverter is incompatible with JSON:API and must not be used.");
}

public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
{
throw new InvalidOperationException("JsonInheritanceConverter is incompatible with JSON:API and must not be used.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,8 @@
<NSwagGenerateNullableReferenceTypes>true</NSwagGenerateNullableReferenceTypes>
<NSwagGenerateOptionalPropertiesAsNullable>true</NSwagGenerateOptionalPropertiesAsNullable>
<NSwagGenerateOptionalParameters>true</NSwagGenerateOptionalParameters>
<NSwagClientBaseClass>JsonApiDotNetCore.OpenApi.Client.NSwag.JsonApiClient</NSwagClientBaseClass>
<NSwagClassStyle>Prism</NSwagClassStyle>
<NSwagTemplateDirectory>$(MSBuildThisFileDirectory)../Templates</NSwagTemplateDirectory>
</PropertyGroup>
</Project>
Loading
Loading