Skip to content

Commit

Permalink
Merge pull request #740 from leancodepl/feature/openapi
Browse files Browse the repository at this point in the history
OpenAPI in .NET 9
  • Loading branch information
jakubfijalkowski authored Jan 9, 2025
2 parents 57619a3 + 50df404 commit c89ee37
Show file tree
Hide file tree
Showing 14 changed files with 281 additions and 47 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ but this project DOES NOT adhere to [Semantic Versioning](http://semver.org/).
* Remove LeanCode.PdfRocket
* Remove StyleCop completely
* JSON serializer for CQRS now shares options with ASP.NET Core's `JsonOptions` by default
* Integrates with Microsoft.AspNetCore.OpenApi better

## 8.1

Expand Down
4 changes: 4 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="$(AspNetCoreVersion)" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="$(AspNetCoreVersion)" />
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="$(AspNetCoreVersion)" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="$(AspNetCoreVersion)" />

<!-- TODO: migrate to v7 (whatever that is) -->
<PackageVersion Include="Microsoft.AspNetCore.Razor.Language" Version="6.0.36" />
Expand Down Expand Up @@ -108,6 +109,9 @@
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.10.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.10.1" />

<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="7.2.0" />
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="7.2.0" />

<PackageVersion Include="NUlid" Version="1.7.2" />
</ItemGroup>

Expand Down
14 changes: 14 additions & 0 deletions LeanCode.CoreLibrary.sln
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Helpers", "Helpers", "{29CC
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LeanCode.UserIdExtractors.Tests", "test\Helpers\LeanCode.UserIdExtractors.Tests\LeanCode.UserIdExtractors.Tests.csproj", "{8E57E9DE-108B-46D8-A7FF-65D28C21A3DF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LeanCode.TestBed", "test-bed\LeanCode.TestBed.csproj", "{E0B2F070-425B-4453-80AE-EE23680F41D8}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -1123,6 +1125,18 @@ Global
{8E57E9DE-108B-46D8-A7FF-65D28C21A3DF}.Release|x64.Build.0 = Release|Any CPU
{8E57E9DE-108B-46D8-A7FF-65D28C21A3DF}.Release|x86.ActiveCfg = Release|Any CPU
{8E57E9DE-108B-46D8-A7FF-65D28C21A3DF}.Release|x86.Build.0 = Release|Any CPU
{E0B2F070-425B-4453-80AE-EE23680F41D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E0B2F070-425B-4453-80AE-EE23680F41D8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E0B2F070-425B-4453-80AE-EE23680F41D8}.Debug|x64.ActiveCfg = Debug|Any CPU
{E0B2F070-425B-4453-80AE-EE23680F41D8}.Debug|x64.Build.0 = Debug|Any CPU
{E0B2F070-425B-4453-80AE-EE23680F41D8}.Debug|x86.ActiveCfg = Debug|Any CPU
{E0B2F070-425B-4453-80AE-EE23680F41D8}.Debug|x86.Build.0 = Debug|Any CPU
{E0B2F070-425B-4453-80AE-EE23680F41D8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E0B2F070-425B-4453-80AE-EE23680F41D8}.Release|Any CPU.Build.0 = Release|Any CPU
{E0B2F070-425B-4453-80AE-EE23680F41D8}.Release|x64.ActiveCfg = Release|Any CPU
{E0B2F070-425B-4453-80AE-EE23680F41D8}.Release|x64.Build.0 = Release|Any CPU
{E0B2F070-425B-4453-80AE-EE23680F41D8}.Release|x86.ActiveCfg = Release|Any CPU
{E0B2F070-425B-4453-80AE-EE23680F41D8}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
29 changes: 9 additions & 20 deletions docs/external_integrations/api_explorer/index.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
# API Explorer/Swagger integration

[CQRS](../../cqrs/index.md) implementation integrates seamlessly with the [API Explorer](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.mvc.apiexplorer) functionality of ASP.NET Core. This means that every endpoint can be automatically documented by other tools that leverage it, e.g. [Swashbuckle](https://github.com/domaindrivendev/Swashbuckle.AspNetCore) or [NSwag](https://github.com/RicoSuter/NSwag).

By using these tools, you get OpenAPI definitions, along the [Swagger UI](https://swagger.io/tools/swagger-ui/) tooling.
[CQRS](../../cqrs/index.md) implementation integrates seamlessly with the [OpenAPI](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/openapi/overview) support of ASP.NET Core. This means that every endpoint can be automatically documented by other tools that leverage it, e.g. [SwaggerUI](https://swagger.io/tools/swaggerhub/) or [ReDoc](https://github.com/Redocly/redoc).

## Packages

Expand All @@ -22,24 +20,15 @@ public override void ConfigureServices(IServiceCollection services)
```

The `AddCQRSApiExplorer` method has an optional parameter that accepts a configuration override.
For now, it only allows to set a custom mapping of the `RouteValues` parameter,
which is used by OpenAPI generation tools to pass some metadata, by which the endpoints
could be grouped on the OpenAPI UI, for example. Usage of this metadata varies between
different OpenAPI UI implementations, so please check the tools documentation before overriding it.

## JSON Casing
It allows configuring what metadata is passed to the OpenAPI tooling, including:

Every integration uses different configuration for the generated payload types. It is highly probable that the default configuration of JSON serializer for these integrations will use wrong property casing. Unfortunately, every tool is configured differently.
1. Tags,
2. Summary,
3. Description.

### Swashbuckle
See the [implementation] for defaults.

To configure Swashbuckle, you need to register custom `ISerializerDataContractResolver` that has the same configurtion as the `ISerializer` of CQRS.
To better accomodate common tag patterns, we also provide [ApiDescriptionTags] class with predefined tag generation based on the namespace of the command/query/operation.

```csharp
// This will use default JSON serialization, with unchanged property names (this is the default of System.Text.Json) & CQRS
builder
.Services
.AddTransient<ISerializerDataContractResolver>(
_ => new JsonSerializerDataContractResolver(new JsonSerializerOptions { PropertyNamingPolicy = null })
);
```
[implementation]: https://github.com/leancodepl/corelibrary/blob/HEAD/src/CQRS/LeanCode.CQRS.AspNetCore/Registration/CQRSApiDescriptionConfiguration.cs
[ApiDescriptionTags]: https://github.com/leancodepl/corelibrary/blob/HEAD/src/CQRS/LeanCode.CQRS.AspNetCore/Registration/ApiDescriptionTags.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using LeanCode.CQRS.Execution;
using Microsoft.AspNetCore.Routing;

namespace LeanCode.CQRS.AspNetCore.Registration;

public static class ApiDescriptionTags
{
public static IReadOnlyList<string> FullNamespace(RouteEndpoint _, CQRSObjectMetadata m)
{
return [(m.ObjectType.Namespace ?? "").Replace(".", " - ", StringComparison.InvariantCulture)];
}

public static Func<RouteEndpoint, CQRSObjectMetadata, IReadOnlyList<string>> SkipNamespacePrefix(int skipFirst)
{
return (_, m) =>
{
var parts = (m.ObjectType.Namespace ?? "").Split('.').Skip(skipFirst);
return [string.Join(" - ", parts)];
};
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
using System.Collections.Frozen;
using LeanCode.CQRS.Execution;
using Microsoft.AspNetCore.Routing;

namespace LeanCode.CQRS.AspNetCore.Registration;

public class CQRSApiDescriptionConfiguration
{
// Required by Swagger (Swashbuckle)
public static readonly FrozenDictionary<string, string?> DefaultRouteValues = new Dictionary<string, string?>
{
["controller"] = "CQRS",
}.ToFrozenDictionary();
public Func<RouteEndpoint, CQRSObjectMetadata, IReadOnlyList<string>> TagsMapping { get; init; } =
ApiDescriptionTags.FullNamespace;

public Func<RouteEndpoint, IDictionary<string, string?>> RouteValuesMapping { get; init; } =
_ => DefaultRouteValues;
public Func<RouteEndpoint, CQRSObjectMetadata, string> SummaryMapping { get; init; } =
static (_, m) => m.ObjectType.FullName ?? "";

public Func<RouteEndpoint, CQRSObjectMetadata, string> DescriptionMapping { get; init; } =
static (_, m) => $"Executed by {m.HandlerType.FullName ?? "unknown handler"}";
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Text;
using LeanCode.Contracts;
using LeanCode.CQRS.Execution;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.ModelBinding;
Expand Down Expand Up @@ -55,7 +56,13 @@ private ApiDescription CreateApiDescription(RouteEndpoint routeEndpoint, CQRSObj
ActionDescriptor = new ActionDescriptor
{
DisplayName = routeEndpoint.DisplayName,
RouteValues = configuration.RouteValuesMapping(routeEndpoint),
EndpointMetadata =
[
metadata,
new EndpointTags(configuration.TagsMapping(routeEndpoint, metadata)),
new EndpointSummary(configuration.SummaryMapping(routeEndpoint, metadata)),
new EndpointDescription(configuration.DescriptionMapping(routeEndpoint, metadata)),
],
},
};
apiDescription.SupportedRequestFormats.Add(new() { MediaType = ApplicationJson });
Expand Down Expand Up @@ -181,7 +188,6 @@ private static void AddCommonResponses(ApiDescription apiDescription)
ModelMetadata = CreateModelMetadata(typeof(void)),
ApiResponseFormats = [new() { MediaType = ApplicationJson }],
StatusCode = 400,
Type = typeof(void),
}
);

Expand All @@ -191,7 +197,6 @@ private static void AddCommonResponses(ApiDescription apiDescription)
ModelMetadata = CreateModelMetadata(typeof(void)),
ApiResponseFormats = [new() { MediaType = ApplicationJson }],
StatusCode = 401,
Type = typeof(void),
}
);

Expand All @@ -201,7 +206,6 @@ private static void AddCommonResponses(ApiDescription apiDescription)
ModelMetadata = CreateModelMetadata(typeof(void)),
ApiResponseFormats = [new() { MediaType = ApplicationJson }],
StatusCode = 403,
Type = typeof(void),
}
);
}
Expand Down
18 changes: 18 additions & 0 deletions src/CQRS/LeanCode.CQRS.AspNetCore/Registration/EndpointMetadata.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using Microsoft.AspNetCore.Http.Metadata;

namespace LeanCode.CQRS.AspNetCore.Registration;

internal class EndpointTags(IReadOnlyList<string> tags) : ITagsMetadata
{
public IReadOnlyList<string> Tags => tags;
}

internal class EndpointSummary(string summary) : IEndpointSummaryMetadata
{
public string Summary => summary;
}

internal class EndpointDescription(string description) : IEndpointDescriptionMetadata
{
public string Description => description;
}
44 changes: 44 additions & 0 deletions test-bed/Api/TestCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System.ComponentModel;
using FluentValidation;
using LeanCode.Contracts;
using LeanCode.Contracts.Security;
using LeanCode.CQRS.Execution;
using LeanCode.CQRS.Validation.Fluent;
using Serilog;
using ILogger = Serilog.ILogger;

namespace LeanCode.TestBed.Api;

[AllowUnauthorized]
public class TestCommand : ICommand
{
public required bool TriggerCode1 { get; set; }
public required bool TriggerCode2 { get; set; }
public required string OtherParameter { get; set; }

public static class ErrorCodes
{
public const int Code1 = 1;
public const int Code2 = 2;
}
}

public class TestCommandCV : AbstractValidator<TestCommand>
{
public TestCommandCV()
{
RuleFor(e => e.TriggerCode1).Equal(false).WithCode(TestCommand.ErrorCodes.Code1);
RuleFor(e => e.TriggerCode2).Equal(false).WithCode(TestCommand.ErrorCodes.Code2);
}
}

public class TestCommandCH : ICommandHandler<TestCommand>
{
private readonly ILogger logger = Log.ForContext<TestCommandCH>();

public Task ExecuteAsync(HttpContext context, TestCommand command)
{
logger.Information("Executing command {Command}", command);
return Task.CompletedTask;
}
}
29 changes: 29 additions & 0 deletions test-bed/Api/TestQuery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using LeanCode.Contracts;
using LeanCode.CQRS.Execution;

namespace LeanCode.TestBed.Api;

[Obsolete]
public class TestQuery : IQuery<TestQueryResult> { }

public class TestQueryResult
{
public Guid Id { get; set; }
public string Property1 { get; set; }
public TestQueryResult? Inner { get; set; }
}

public class TestQueryQH : IQueryHandler<TestQuery, TestQueryResult>
{
public Task<TestQueryResult> ExecuteAsync(HttpContext context, TestQuery query)
{
return Task.FromResult(
new TestQueryResult
{
Id = Guid.NewGuid(),
Property1 = "Some value",
Inner = new() { Id = Guid.NewGuid(), Property1 = "Other value" },
}
);
}
}
27 changes: 27 additions & 0 deletions test-bed/LeanCode.TestBed.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="LeanCode.Contracts" />

<PackageReference Include="Serilog" />
<PackageReference Include="Serilog.Extensions.Logging" />

<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Swashbuckle.AspNetCore.ReDoc" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" />

<ProjectReference Include="../src/Core/LeanCode.Components/LeanCode.Components.csproj" />

<ProjectReference Include="../src/CQRS/LeanCode.CQRS.AspNetCore/LeanCode.CQRS.AspNetCore.csproj" />
<ProjectReference Include="../src/CQRS/LeanCode.CQRS.Validation/LeanCode.CQRS.Validation.csproj" />
<ProjectReference Include="../src/CQRS/LeanCode.CQRS.Validation.Fluent/LeanCode.CQRS.Validation.Fluent.csproj" />
</ItemGroup>

</Project>
37 changes: 37 additions & 0 deletions test-bed/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using LeanCode.Components;
using LeanCode.CQRS.AspNetCore;
using LeanCode.CQRS.Validation.Fluent;
using LeanCode.TestBed.Api;
using Serilog;

var builder = WebApplication.CreateBuilder(args);

builder.Logging.AddSerilog();
builder.Services.AddFluentValidation(TypesCatalog.Of<TestCommandCH>());
builder.Services.AddCQRS(TypesCatalog.Of<TestCommand>(), TypesCatalog.Of<TestCommandCH>());
builder.Services.AddCQRSApiExplorer();
builder.Services.AddOpenApi();

var app = builder.Build();

app.MapOpenApi();
app.MapRemoteCQRS(
"/",
c =>
{
c.Commands = p => p.Secure().Validate();
c.Queries = p => p.Secure();
}
);
app.UseReDoc(opt =>
{
opt.SpecUrl("/openapi/v1.json");
opt.RoutePrefix = "redoc";
});
app.UseSwaggerUI(opt =>
{
opt.SwaggerEndpoint("/openapi/v1.json", "Test API");
opt.RoutePrefix = "swagger";
});

app.Run();
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using FluentAssertions;
using LeanCode.CQRS.AspNetCore.Registration;
using LeanCode.CQRS.Execution;
using Xunit;

namespace LeanCode.CQRS.AspNetCore.Tests.Registration;

public class ApiDescriptionTagsTests
{
private static readonly CQRSObjectMetadata Metadata = new CQRSObjectMetadata(
CQRSObjectKind.Command,
typeof(ApiDescriptionTagsTests),
typeof(ApiDescriptionTagsTests),
typeof(ApiDescriptionTagsTests),
(_, _) => Task.FromResult<object?>(null)
);

[Fact]
public void FullNamespace_returns_full_namespace_with_dot_replaced()
{
var tags = ApiDescriptionTags.FullNamespace(null!, Metadata);

tags.Should().BeEquivalentTo("LeanCode - CQRS - AspNetCore - Tests - Registration");
}

[Fact]
public void SkipNamespacePrefix_skips_initial_namespace_parts()
{
Tag(skip: 0).Should().BeEquivalentTo("LeanCode - CQRS - AspNetCore - Tests - Registration");
Tag(skip: 1).Should().BeEquivalentTo("CQRS - AspNetCore - Tests - Registration");
Tag(skip: 2).Should().BeEquivalentTo("AspNetCore - Tests - Registration");
Tag(skip: 10).Should().BeEquivalentTo("");

IReadOnlyList<string> Tag(int skip) => ApiDescriptionTags.SkipNamespacePrefix(skip)(null!, Metadata);
}
}
Loading

0 comments on commit c89ee37

Please sign in to comment.