diff --git a/CHANGELOG.md b/CHANGELOG.md
index 386b556d..54f4bd11 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/Directory.Packages.props b/Directory.Packages.props
index fc3533da..63d186f2 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -63,6 +63,7 @@
+
@@ -108,6 +109,9 @@
+
+
+
diff --git a/LeanCode.CoreLibrary.sln b/LeanCode.CoreLibrary.sln
index 9018c56e..3885deb1 100755
--- a/LeanCode.CoreLibrary.sln
+++ b/LeanCode.CoreLibrary.sln
@@ -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
@@ -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
diff --git a/docs/external_integrations/api_explorer/index.md b/docs/external_integrations/api_explorer/index.md
index 02020188..690c388c 100644
--- a/docs/external_integrations/api_explorer/index.md
+++ b/docs/external_integrations/api_explorer/index.md
@@ -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
@@ -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(
- _ => 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
diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Registration/ApiDescriptionTags.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Registration/ApiDescriptionTags.cs
new file mode 100644
index 00000000..6a9ed517
--- /dev/null
+++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Registration/ApiDescriptionTags.cs
@@ -0,0 +1,21 @@
+using LeanCode.CQRS.Execution;
+using Microsoft.AspNetCore.Routing;
+
+namespace LeanCode.CQRS.AspNetCore.Registration;
+
+public static class ApiDescriptionTags
+{
+ public static IReadOnlyList FullNamespace(RouteEndpoint _, CQRSObjectMetadata m)
+ {
+ return [(m.ObjectType.Namespace ?? "").Replace(".", " - ", StringComparison.InvariantCulture)];
+ }
+
+ public static Func> SkipNamespacePrefix(int skipFirst)
+ {
+ return (_, m) =>
+ {
+ var parts = (m.ObjectType.Namespace ?? "").Split('.').Skip(skipFirst);
+ return [string.Join(" - ", parts)];
+ };
+ }
+}
diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Registration/CQRSApiDescriptionConfiguration.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Registration/CQRSApiDescriptionConfiguration.cs
index 60a60e6b..c865251d 100644
--- a/src/CQRS/LeanCode.CQRS.AspNetCore/Registration/CQRSApiDescriptionConfiguration.cs
+++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Registration/CQRSApiDescriptionConfiguration.cs
@@ -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 DefaultRouteValues = new Dictionary
- {
- ["controller"] = "CQRS",
- }.ToFrozenDictionary();
+ public Func> TagsMapping { get; init; } =
+ ApiDescriptionTags.FullNamespace;
- public Func> RouteValuesMapping { get; init; } =
- _ => DefaultRouteValues;
+ public Func SummaryMapping { get; init; } =
+ static (_, m) => m.ObjectType.FullName ?? "";
+
+ public Func DescriptionMapping { get; init; } =
+ static (_, m) => $"Executed by {m.HandlerType.FullName ?? "unknown handler"}";
}
diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Registration/CQRSApiDescriptionProvider.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Registration/CQRSApiDescriptionProvider.cs
index 8905f7d4..23dcfb27 100644
--- a/src/CQRS/LeanCode.CQRS.AspNetCore/Registration/CQRSApiDescriptionProvider.cs
+++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Registration/CQRSApiDescriptionProvider.cs
@@ -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;
@@ -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 });
@@ -181,7 +188,6 @@ private static void AddCommonResponses(ApiDescription apiDescription)
ModelMetadata = CreateModelMetadata(typeof(void)),
ApiResponseFormats = [new() { MediaType = ApplicationJson }],
StatusCode = 400,
- Type = typeof(void),
}
);
@@ -191,7 +197,6 @@ private static void AddCommonResponses(ApiDescription apiDescription)
ModelMetadata = CreateModelMetadata(typeof(void)),
ApiResponseFormats = [new() { MediaType = ApplicationJson }],
StatusCode = 401,
- Type = typeof(void),
}
);
@@ -201,7 +206,6 @@ private static void AddCommonResponses(ApiDescription apiDescription)
ModelMetadata = CreateModelMetadata(typeof(void)),
ApiResponseFormats = [new() { MediaType = ApplicationJson }],
StatusCode = 403,
- Type = typeof(void),
}
);
}
diff --git a/src/CQRS/LeanCode.CQRS.AspNetCore/Registration/EndpointMetadata.cs b/src/CQRS/LeanCode.CQRS.AspNetCore/Registration/EndpointMetadata.cs
new file mode 100644
index 00000000..22d77b05
--- /dev/null
+++ b/src/CQRS/LeanCode.CQRS.AspNetCore/Registration/EndpointMetadata.cs
@@ -0,0 +1,18 @@
+using Microsoft.AspNetCore.Http.Metadata;
+
+namespace LeanCode.CQRS.AspNetCore.Registration;
+
+internal class EndpointTags(IReadOnlyList tags) : ITagsMetadata
+{
+ public IReadOnlyList Tags => tags;
+}
+
+internal class EndpointSummary(string summary) : IEndpointSummaryMetadata
+{
+ public string Summary => summary;
+}
+
+internal class EndpointDescription(string description) : IEndpointDescriptionMetadata
+{
+ public string Description => description;
+}
diff --git a/test-bed/Api/TestCommand.cs b/test-bed/Api/TestCommand.cs
new file mode 100644
index 00000000..9da145e9
--- /dev/null
+++ b/test-bed/Api/TestCommand.cs
@@ -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
+{
+ 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
+{
+ private readonly ILogger logger = Log.ForContext();
+
+ public Task ExecuteAsync(HttpContext context, TestCommand command)
+ {
+ logger.Information("Executing command {Command}", command);
+ return Task.CompletedTask;
+ }
+}
diff --git a/test-bed/Api/TestQuery.cs b/test-bed/Api/TestQuery.cs
new file mode 100644
index 00000000..70ae9153
--- /dev/null
+++ b/test-bed/Api/TestQuery.cs
@@ -0,0 +1,29 @@
+using LeanCode.Contracts;
+using LeanCode.CQRS.Execution;
+
+namespace LeanCode.TestBed.Api;
+
+[Obsolete]
+public class TestQuery : IQuery { }
+
+public class TestQueryResult
+{
+ public Guid Id { get; set; }
+ public string Property1 { get; set; }
+ public TestQueryResult? Inner { get; set; }
+}
+
+public class TestQueryQH : IQueryHandler
+{
+ public Task ExecuteAsync(HttpContext context, TestQuery query)
+ {
+ return Task.FromResult(
+ new TestQueryResult
+ {
+ Id = Guid.NewGuid(),
+ Property1 = "Some value",
+ Inner = new() { Id = Guid.NewGuid(), Property1 = "Other value" },
+ }
+ );
+ }
+}
diff --git a/test-bed/LeanCode.TestBed.csproj b/test-bed/LeanCode.TestBed.csproj
new file mode 100644
index 00000000..9676d065
--- /dev/null
+++ b/test-bed/LeanCode.TestBed.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net9.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test-bed/Program.cs b/test-bed/Program.cs
new file mode 100644
index 00000000..78aad49d
--- /dev/null
+++ b/test-bed/Program.cs
@@ -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());
+builder.Services.AddCQRS(TypesCatalog.Of(), TypesCatalog.Of());
+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();
diff --git a/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Registration/ApiDescriptionTagsTests.cs b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Registration/ApiDescriptionTagsTests.cs
new file mode 100644
index 00000000..0ad65f7d
--- /dev/null
+++ b/test/CQRS/LeanCode.CQRS.AspNetCore.Tests/Registration/ApiDescriptionTagsTests.cs
@@ -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