diff --git a/src/Cnblogs.Architecture.ServiceAgent.Design/EndpointManifestBuilder.cs b/src/Cnblogs.Architecture.ServiceAgent.Design/EndpointManifestBuilder.cs index 1598ede..90f8732 100644 --- a/src/Cnblogs.Architecture.ServiceAgent.Design/EndpointManifestBuilder.cs +++ b/src/Cnblogs.Architecture.ServiceAgent.Design/EndpointManifestBuilder.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Text.RegularExpressions; +using Asp.Versioning; using Cnblogs.Architecture.Ddd.Cqrs.AspNetCore; using Microsoft.AspNetCore.Routing; @@ -79,10 +80,28 @@ private static ManifestEndpoint BuildEndpoint(RouteEndpoint endpoint, CqrsEndpoi RequestTypeName = descriptor.RequestType.Name, Parameters = descriptor.Parameters.Select(BuildParameter).ToList(), NullableRouteParameters = descriptor.NullableRouteParameters.ToList(), - EnableHead = descriptor.EnableHead + EnableHead = descriptor.EnableHead, + ApiVersions = GetApiVersions(endpoint) }; } + private static List GetApiVersions(RouteEndpoint endpoint) + { + // Asp.Versioning attaches its endpoint metadata when the endpoint is mapped under a versioned group or + // controller; endpoints without it are unversioned (exported with an empty version list). + var metadata = endpoint.Metadata.GetMetadata(); + if (metadata is null || metadata.IsApiVersionNeutral) + { + return []; + } + + return metadata.Map(ApiVersionMapping.Explicit | ApiVersionMapping.Implicit) + .DeclaredApiVersions + .Select(v => v.ToString("VVV", CultureInfo.InvariantCulture)) + .Distinct(StringComparer.Ordinal) + .ToList(); + } + private static ManifestParameter BuildParameter(EndpointParameterDescriptor parameter) { return new ManifestParameter diff --git a/src/Cnblogs.Architecture.ServiceAgent.Design/ManifestEndpoint.cs b/src/Cnblogs.Architecture.ServiceAgent.Design/ManifestEndpoint.cs index 18915a4..00bc388 100644 --- a/src/Cnblogs.Architecture.ServiceAgent.Design/ManifestEndpoint.cs +++ b/src/Cnblogs.Architecture.ServiceAgent.Design/ManifestEndpoint.cs @@ -43,4 +43,12 @@ public sealed class ManifestEndpoint /// Whether HEAD is also mapped (the generator emits an extra HasXxxAsync). public bool EnableHead { get; set; } + + /// + /// The API versions this endpoint is declared for, as reported by API versioning metadata (e.g. + /// ["1"] for a .HasApiVersion(1) group, ["1", "2"] when mapped under multiple versions). + /// Empty when the endpoint carries no API-versioning metadata. Used by the generator to select or split + /// generated agents per API version. + /// + public List ApiVersions { get; set; } = []; } diff --git a/src/Cnblogs.Architecture.Tool/GenerateOptions.cs b/src/Cnblogs.Architecture.Tool/GenerateOptions.cs index d2b37d4..5f19604 100644 --- a/src/Cnblogs.Architecture.Tool/GenerateOptions.cs +++ b/src/Cnblogs.Architecture.Tool/GenerateOptions.cs @@ -1 +1,7 @@ -internal sealed record GenerateOptions(string ApiProject, string Output, string Namespace, bool Clean, string? BaseUrl); +internal sealed record GenerateOptions( + string ApiProject, + string Output, + string Namespace, + bool Clean, + string? BaseUrl, + string? ApiVersion); diff --git a/src/Cnblogs.Architecture.Tool/Generation/ServiceAgentEmitter.cs b/src/Cnblogs.Architecture.Tool/Generation/ServiceAgentEmitter.cs index 707bb85..b997802 100644 --- a/src/Cnblogs.Architecture.Tool/Generation/ServiceAgentEmitter.cs +++ b/src/Cnblogs.Architecture.Tool/Generation/ServiceAgentEmitter.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text; using System.Text.RegularExpressions; using Cnblogs.Architecture.Tool.Manifest; @@ -35,10 +36,21 @@ internal sealed class ServiceAgentEmitter /// /// The API version substituted for {version:apiVersion} route tokens (the API-versioning route-group - /// convention). Defaults to "1". + /// convention) when an endpoint declares no API versions of its own. Each endpoint's declared version (from + /// API-versioning metadata) takes precedence: the first declared version wins, or this value for unversioned + /// endpoints. /// public string ApiVersion { get; init; } = "1"; + /// + /// When set, only endpoints declaring this API version are emitted, as one un-suffixed + /// IXxxService per group. When null (the default), all endpoints are emitted; a group whose + /// endpoints span multiple API versions is split into IXxxV1Service, IXxxV2Service, ... per + /// version (a single-version or unversioned group keeps the un-suffixed name). Supplied via the tool's + /// --api-version option. + /// + public string? RequestedApiVersion { get; init; } + /// /// When set, the generated DI extensions bake this base URL into each AddXxxService call and the methods /// take no baseUri argument. Supplied via the tool's --base-url option; null keeps the @@ -52,6 +64,7 @@ public List Emit(EndpointManifest manifest, string @namespace) _diagnostics.Clear(); _pocoNameByBodyType.Clear(); _pocoDefinitions.Clear(); + manifest = ApplyApiVersionPolicy(manifest); BuildPocoTables(manifest); var files = new List(); foreach (var group in manifest.Groups) @@ -84,10 +97,137 @@ public List Emit(EndpointManifest manifest, string @namespace) } /// - /// Assign a generated POCO name to each distinct command body type that carries a payload contract, reserving - /// the names the generator already emits (service classes, the extensions class) so a derived payload name - /// cannot collide. One command mapped at several routes shares a single POCO. + /// Apply the API-version policy to the manifest: with set, drop endpoints + /// not declaring that version; otherwise split each group spanning multiple declared versions into one group + /// per version (suffixed XxxV2), so the generated types do not collide. Each resulting group carries + /// the API version its {version:apiVersion} route tokens are stamped with during URL rendering. /// + private EndpointManifest ApplyApiVersionPolicy(EndpointManifest manifest) + { + List groups; + if (RequestedApiVersion is not null) + { + var requested = VersionKey(RequestedApiVersion); + groups = manifest.Groups + .Select(g => new ManifestGroup + { + Name = g.Name, + ErrorType = g.ErrorType, + ApiVersion = RequestedApiVersion, + Endpoints = g.Endpoints + .Where(e => e.ApiVersions.Count == 0 + || e.ApiVersions.Any(v => VersionKey(v) == requested)) + .ToList() + }) + .Where(g => g.Endpoints.Count > 0) + .ToList(); + + var dropped = manifest.Groups.Sum(g => g.Endpoints.Count) - groups.Sum(g => g.Endpoints.Count); + if (dropped > 0) + { + _diagnostics.Add( + $"Dropped {dropped} endpoint(s) not declaring API version '{RequestedApiVersion}'."); + } + } + else + { + groups = manifest.Groups.SelectMany(SplitGroupByApiVersion).ToList(); + } + + EnsureUniqueGroupNames(groups); + return new EndpointManifest { SchemaVersion = manifest.SchemaVersion, Groups = groups }; + } + + private static List SplitGroupByApiVersion(ManifestGroup group) + { + // An endpoint declared under multiple versions joins each version's group; an unversioned endpoint stays in + // the un-suffixed group. A single (or absent) version across the group keeps the original name — the V-suffix + // only exists to disambiguate concurrent versions. + var versions = group.Endpoints + .SelectMany(e => e.ApiVersions) + .Select(VersionKey) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + if (versions.Count <= 1) + { + return + [ + new ManifestGroup + { + Name = group.Name, + ErrorType = group.ErrorType, + // Null (no version metadata anywhere) keeps the emitter-level default stamp. + ApiVersion = versions.Count == 1 ? versions[0] : null, + Endpoints = group.Endpoints + } + ]; + } + + var result = versions + .Select( + version => new ManifestGroup + { + Name = group.Name + "V" + VersionSuffix(version), + ErrorType = group.ErrorType, + ApiVersion = version, + Endpoints = group.Endpoints + .Where(e => e.ApiVersions.Any(v => VersionKey(v) == version)) + .ToList() + }) + .ToList(); + + var unversioned = group.Endpoints.Where(e => e.ApiVersions.Count == 0).ToList(); + if (unversioned.Count > 0) + { + result.Add( + new ManifestGroup + { + Name = group.Name, + ErrorType = group.ErrorType, + ApiVersion = null, + Endpoints = unversioned + }); + } + + return result; + } + + /// Canonical comparison form of an API version, so "2" and "2.0" match (Asp.Versioning treats them equal). + private static string VersionKey(string version) + { + if (Version.TryParse(version.Trim(), out var parsed)) + { + return parsed.Minor > 0 + ? $"{parsed.Major}.{parsed.Minor}" + : parsed.Major.ToString(CultureInfo.InvariantCulture); + } + + return version.Trim(); + } + + /// An identifier-safe suffix for group names (e.g. "2""2", "2.1""2_1"). + private static string VersionSuffix(string versionKey) + { + return new string(versionKey.Select(c => char.IsLetterOrDigit(c) ? c : '_').ToArray()); + } + + private static void EnsureUniqueGroupNames(List groups) + { + // Splitting can collide with an explicit WithServiceAgentGroup name (e.g. a pre-existing "AccusationV2" + // group next to a split-out "Accusation" + V2). That cannot be silently resolved; fail like the exporter does. + var duplicates = groups.GroupBy(g => g.Name, StringComparer.OrdinalIgnoreCase) + .Where(g => g.Count() > 1) + .Select(g => g.Key) + .ToList(); + if (duplicates.Count > 0) + { + throw new InvalidOperationException( + "API-version splitting produced duplicate group names (" + + string.Join(", ", duplicates) + + "). Rename the colliding WithServiceAgentGroup or unify the route groups."); + } + } + private void BuildPocoTables(EndpointManifest manifest) { var reserved = new HashSet(StringComparer.Ordinal) { "ServiceAgentExtensions" }; @@ -130,11 +270,12 @@ private List BuildPlans(ManifestGroup group, ClrTypeRenderer rendere renderer.Render(group.ErrorType); } + var apiVersion = group.ApiVersion ?? ApiVersion; var clusters = ClusterEndpoints(group.Endpoints); var plans = new List(); foreach (var cluster in clusters) { - plans.AddRange(BuildPlansForCluster(cluster, renderer)); + plans.AddRange(BuildPlansForCluster(cluster, renderer, apiVersion)); } // C# requires required parameters before optional ones; stable-partition so this holds regardless of how @@ -211,7 +352,10 @@ private static string NormalizeRoute(string route) return string.Join('/', segments); } - private List BuildPlansForCluster(List cluster, ClrTypeRenderer renderer) + private List BuildPlansForCluster( + List cluster, + ClrTypeRenderer renderer, + string apiVersion) { var canonical = cluster.OrderByDescending(e => e.Route.Count(c => c == '{')).First(); var name = DeriveMethodName(canonical.RequestTypeName); @@ -220,7 +364,7 @@ private List BuildPlansForCluster(List cluster, Cl var plans = new List(); // Primary method (GET item/list/paged, or a command). - var primary = BuildPrimaryPlan(canonical, name, nullableRouteParams, renderer); + var primary = BuildPrimaryPlan(canonical, name, nullableRouteParams, renderer, apiVersion); plans.Add(primary); // HEAD companion for single-item queries that enabled it. Guard on the skip flag (BuildPrimaryPlan returns @@ -229,7 +373,7 @@ private List BuildPlansForCluster(List cluster, Cl if (canonical is { IsQuery: true, EnableHead: true, ResponseShape: ResponseShape.Item } && primary is { IsSkipped: false }) { - plans.Add(BuildHeadPlan(canonical, name, nullableRouteParams, renderer)); + plans.Add(BuildHeadPlan(canonical, name, nullableRouteParams, renderer, apiVersion)); } return plans; @@ -239,11 +383,12 @@ private MethodPlan BuildPrimaryPlan( ManifestEndpoint endpoint, string name, HashSet nullableRouteParams, - ClrTypeRenderer renderer) + ClrTypeRenderer renderer, + string apiVersion) { var paramNames = new HashSet(StringComparer.Ordinal); - var (urlExpr, routeParams, urlDiagnostic) = BuildUrlExpression(endpoint, nullableRouteParams); + var (urlExpr, routeParams, urlDiagnostic) = BuildUrlExpression(endpoint, nullableRouteParams, apiVersion); if (urlDiagnostic is not null) { _diagnostics.Add($"Skipped '{name}' ({endpoint.HttpMethod} {endpoint.Route}): {urlDiagnostic}."); @@ -323,10 +468,11 @@ private MethodPlan BuildHeadPlan( ManifestEndpoint endpoint, string name, HashSet nullableRouteParams, - ClrTypeRenderer renderer) + ClrTypeRenderer renderer, + string apiVersion) { var paramNames = new HashSet(StringComparer.Ordinal); - var (urlExpr, routeParams, _) = BuildUrlExpression(endpoint, nullableRouteParams); + var (urlExpr, routeParams, _) = BuildUrlExpression(endpoint, nullableRouteParams, apiVersion); var queryParams = endpoint.Parameters.Where(p => p.Source == ParameterSource.Query && !IsPagingProperty(p.Name)) .ToList(); var signature = BuildQuerySignature(routeParams, queryParams, false, renderer, paramNames); @@ -541,7 +687,8 @@ private static MethodParam BuildBodyParam( private (string UrlExpression, List RouteParams, string? Diagnostic) BuildUrlExpression( ManifestEndpoint endpoint, - HashSet nullableRouteParams) + HashSet nullableRouteParams, + string apiVersion) { var route = endpoint.Route; var routeParams = new List(); @@ -567,7 +714,7 @@ private static MethodParam BuildBodyParam( // the configured version rather than treated as a missing parameter. if (IsVersionToken(tokenName, constraint)) { - sb.Append(ApiVersion); + sb.Append(apiVersion); continue; } diff --git a/src/Cnblogs.Architecture.Tool/Manifest/ManifestEndpoint.cs b/src/Cnblogs.Architecture.Tool/Manifest/ManifestEndpoint.cs index f4fd328..6eb2b8a 100644 --- a/src/Cnblogs.Architecture.Tool/Manifest/ManifestEndpoint.cs +++ b/src/Cnblogs.Architecture.Tool/Manifest/ManifestEndpoint.cs @@ -14,4 +14,5 @@ internal sealed class ManifestEndpoint public List Parameters { get; set; } = []; public List NullableRouteParameters { get; set; } = []; public bool EnableHead { get; set; } + public List ApiVersions { get; set; } = []; } diff --git a/src/Cnblogs.Architecture.Tool/Manifest/ManifestGroup.cs b/src/Cnblogs.Architecture.Tool/Manifest/ManifestGroup.cs index 1e4f922..41ded91 100644 --- a/src/Cnblogs.Architecture.Tool/Manifest/ManifestGroup.cs +++ b/src/Cnblogs.Architecture.Tool/Manifest/ManifestGroup.cs @@ -1,3 +1,5 @@ +using Cnblogs.Architecture.Tool.Generation; + namespace Cnblogs.Architecture.Tool.Manifest; internal sealed class ManifestGroup @@ -5,4 +7,12 @@ internal sealed class ManifestGroup public string Name { get; set; } = string.Empty; public ClrTypeRef? ErrorType { get; set; } public List Endpoints { get; set; } = []; + + /// + /// The API version this group's {version:apiVersion} route tokens are stamped with. Set by + /// from the endpoints' declared versions; null falls back to the + /// emitter-level default. Not serialized — the manifest carries + /// versions per endpoint (), the group value is derived. + /// + public string? ApiVersion { get; set; } } diff --git a/src/Cnblogs.Architecture.Tool/Program.cs b/src/Cnblogs.Architecture.Tool/Program.cs index 24f3f2e..28c7efe 100644 --- a/src/Cnblogs.Architecture.Tool/Program.cs +++ b/src/Cnblogs.Architecture.Tool/Program.cs @@ -50,9 +50,12 @@ static void PrintUsage() serviceagent Generate strongly-typed CQRS service agents. serviceagent generate: - dotnet cnb serviceagent generate --api-project --output --namespace [--base-url ] [--clean] + dotnet cnb serviceagent generate --api-project --output --namespace [--base-url ] [--api-version ] [--clean] - --base-url Bake this base URL into the generated AddXxxService extensions (otherwise each takes a baseUri argument). + --base-url Bake this base URL into the generated AddXxxService extensions (otherwise each takes a baseUri argument). + --api-version Emit only endpoints declared for this API version, as one un-suffixed IXxxService per group. + Omitted: every version is emitted; a group spanning several versions is split into + IXxxV1Service / IXxxV2Service / ... per version. Requires the API project to reference the Cnblogs.Architecture.ServiceAgent.Design package. """); @@ -63,6 +66,7 @@ Requires the API project to reference the Cnblogs.Architecture.ServiceAgent.Desi string? apiProject = null; string? output = null; string? baseUrl = null; + string? apiVersion = null; var ns = "Generated.ServiceAgents"; var clean = false; for (var i = 0; i < args.Length; i++) @@ -81,6 +85,9 @@ Requires the API project to reference the Cnblogs.Architecture.ServiceAgent.Desi case "--base-url": baseUrl = Next(args, ref i); break; + case "--api-version": + apiVersion = Next(args, ref i); + break; case "--clean": clean = true; break; @@ -96,7 +103,7 @@ Requires the API project to reference the Cnblogs.Architecture.ServiceAgent.Desi return null; } - return new GenerateOptions(apiProject, output, ns, clean, baseUrl); + return new GenerateOptions(apiProject, output, ns, clean, baseUrl, apiVersion); } static string? Next(string[] args, ref int i) @@ -153,7 +160,7 @@ static async Task RunGenerateAsync(GenerateOptions options) CleanGeneratedFiles(options.Output); } - var emitter = new ServiceAgentEmitter { BaseUrl = options.BaseUrl }; + var emitter = new ServiceAgentEmitter { BaseUrl = options.BaseUrl, RequestedApiVersion = options.ApiVersion }; var files = emitter.Emit(manifest, options.Namespace); foreach (var diagnostic in emitter.Diagnostics) { diff --git a/src/Cnblogs.Architecture.Tool/README.md b/src/Cnblogs.Architecture.Tool/README.md index 21198dc..998aa77 100644 --- a/src/Cnblogs.Architecture.Tool/README.md +++ b/src/Cnblogs.Architecture.Tool/README.md @@ -44,6 +44,8 @@ Options: | `--api-project` | Path to the API `.csproj` or its directory. | | `--output` | Directory to write the generated `.cs` files into (the client project). | | `--namespace` | Namespace for the generated types. | +| `--base-url` | Bake this base URL into the generated `AddXxxService` extensions (otherwise each takes a `baseUri` argument). | +| `--api-version` | Emit only endpoints declared for this API version (e.g. `--api-version 2`), as one un-suffixed `IXxxService` per group. | | `--clean` | Remove previously generated files in `--output` before writing. | The client project must reference `Cnblogs.Architecture.Ddd.Cqrs.ServiceAgent` (the base class + `AddServiceAgent`) @@ -72,7 +74,8 @@ Endpoint shapes handled: - Mixed route-scalar + body signatures. - Nullable-route expansion (`MapNullableRouteParameter.Enable`) collapsed into a single method that substitutes `"-"` for missing values. -- Route-group API-version tokens (`{version:apiVersion}`) substituted with the configured version (default `1`). +- Route-group API-version tokens (`{version:apiVersion}`) substituted with the endpoint's declared API version + (falling back to `1` for endpoints without version metadata). ## Grouping @@ -86,6 +89,18 @@ v1.MapGroup("/api/v1/store").WithServiceAgentGroup("Store"); A group with conflicting error types, or two groups resolving to the same name, are reported as errors. +## Multiple API versions + +When the API registers versioned endpoints (`.HasApiVersion(...)` / `[ApiVersion]`), each endpoint's declared +versions are exported to the manifest, and the `{version:apiVersion}` route token is stamped with the endpoint's +own version instead of a hard-coded one. + +- **Default (no option):** all endpoints are emitted. A group whose endpoints span several API versions is split + into one agent per version — e.g. `IAccusationV1Service` + `IAccusationV2Service` — each calling its own + `/api/v1/...` / `/api/v2/...` routes. A group within a single version keeps the un-suffixed name. +- **`--api-version 2`:** only endpoints declaring version 2 (or carrying no version metadata) are emitted, as one + un-suffixed `IAccusationService`; endpoints of other versions are dropped with a warning. + ## Limitations - Endpoints whose route tokens have no matching parameter are skipped with a warning (e.g. route-bound paging diff --git a/test/Cnblogs.Architecture.IntegrationTestProject/Program.cs b/test/Cnblogs.Architecture.IntegrationTestProject/Program.cs index 5638c39..207a622 100644 --- a/test/Cnblogs.Architecture.IntegrationTestProject/Program.cs +++ b/test/Cnblogs.Architecture.IntegrationTestProject/Program.cs @@ -56,6 +56,11 @@ (int id, UpdatePayload payload) => new UpdateCommand(id, payload.NeedValidationError, payload.NeedExecutionError)); v1.MapCommand("strings/{id:int}"); +// v2 mirrors part of the v1 surface, so generated agents exercise the multi-version split path. +var v2 = apis.MapGroup("/api/v{version:apiVersion}").HasApiVersion(2); +v2.MapQuery("strings"); +v2.MapQuery("articles/{id:int}"); + // generic command map v1.MapPostCommand("generic-map/strings"); v1.MapPutCommand("generic-map/strings"); diff --git a/test/Cnblogs.Architecture.UnitTests/Cqrs/EndpointManifestBuilderTests.cs b/test/Cnblogs.Architecture.UnitTests/Cqrs/EndpointManifestBuilderTests.cs index 3afae48..e3e48fd 100644 --- a/test/Cnblogs.Architecture.UnitTests/Cqrs/EndpointManifestBuilderTests.cs +++ b/test/Cnblogs.Architecture.UnitTests/Cqrs/EndpointManifestBuilderTests.cs @@ -71,11 +71,19 @@ public record MixedCommand2 : ICommand } private static async Task BuildManifestAsync( - Action mapEndpoints) + Action mapEndpoints, + bool useApiVersioning = false) { var builder = WebApplication.CreateBuilder(); // Bind an ephemeral port so parallel WebApplication-based test classes don't fight for the default port. builder.WebHost.UseUrls("http://127.0.0.1:0"); + if (useApiVersioning) + { + // NewVersionedApi() endpoints finalize against the versioning services; register them like the API + // projects that use them do. + builder.Services.AddCnblogsApiVersioning(); + } + var app = builder.Build(); mapEndpoints(app); @@ -156,6 +164,44 @@ public async Task Build_SingleGetQuery_ExpandsParametersAndCarriesRouteAsync() Assert.Null(found.RouteToken); } + [Fact] + public async Task Build_VersionedEndpoints_ExportDeclaredApiVersionsAsync() + { + // Arrange — mirrors the CnblogsReport shape: the same surface mapped under v1 and v2 versioned groups. + // Act + var manifest = await BuildManifestAsync( + app => + { + var apis = app.NewVersionedApi(); + var v1 = apis.MapGroup("/api/v{version:apiVersion}").HasApiVersion(1); + v1.MapQuery("accusations/{stringId:int}"); + var v2 = apis.MapGroup("/api/v{version:apiVersion}").HasApiVersion(2); + v2.MapQuery("accusations/{stringId:int}"); + }, + useApiVersioning: true); + + // Assert — each endpoint carries its group's declared version (as a plain major string). + var endpoints = manifest.Groups.SelectMany(g => g.Endpoints) + .Where(e => e.RequestTypeName == nameof(SingleQuery)) + .ToList(); + Assert.Equal(2, endpoints.Count); + Assert.Contains(endpoints, e => e.ApiVersions.SequenceEqual(["1"])); + Assert.Contains(endpoints, e => e.ApiVersions.SequenceEqual(["2"])); + } + + [Fact] + public async Task Build_UnversionedEndpoint_ExportsEmptyApiVersionsAsync() + { + // Act + var manifest = await BuildManifestAsync( + app => app.MapQuery("apps/{appId}/strings/{stringId:int}/value")); + + // Assert — no versioning metadata on the endpoint means an empty version list (the generator falls back to + // its default stamp for these). + var endpoint = SingleEndpoint(manifest, nameof(SingleQuery)); + Assert.Empty(endpoint.ApiVersions); + } + [Fact] public async Task Build_PostCommand_AttachesBodyPayloadAndErrorTypeAsync() { diff --git a/test/Cnblogs.Architecture.UnitTests/Cqrs/ServiceAgentEmitterTests.cs b/test/Cnblogs.Architecture.UnitTests/Cqrs/ServiceAgentEmitterTests.cs index 2dc060d..1d05fb3 100644 --- a/test/Cnblogs.Architecture.UnitTests/Cqrs/ServiceAgentEmitterTests.cs +++ b/test/Cnblogs.Architecture.UnitTests/Cqrs/ServiceAgentEmitterTests.cs @@ -19,7 +19,8 @@ private static ManifestEndpoint Query( ClrTypeRef responseType, List parameters, bool enableHead = false, - List? nullableRoutes = null) => + List? nullableRoutes = null, + List? apiVersions = null) => new() { HttpMethod = "GET", @@ -31,7 +32,8 @@ private static ManifestEndpoint Query( RequestTypeName = requestTypeName, Parameters = parameters, EnableHead = enableHead, - NullableRouteParameters = nullableRoutes ?? [] + NullableRouteParameters = nullableRoutes ?? [], + ApiVersions = apiVersions ?? [] }; private static ManifestEndpoint Command( @@ -42,7 +44,8 @@ private static ManifestEndpoint Command( ClrTypeRef? responseType, ClrTypeRef? payloadType, List parameters, - PayloadContract? payloadContract = null) => + PayloadContract? payloadContract = null, + List? apiVersions = null) => new() { HttpMethod = verb, @@ -54,13 +57,19 @@ private static ManifestEndpoint Command( PayloadType = payloadType, PayloadContract = payloadContract, RequestTypeName = requestTypeName, - Parameters = parameters + Parameters = parameters, + ApiVersions = apiVersions ?? [] }; private static string EmitClass(params ManifestGroup[] groups) + { + return EmitClass(new ServiceAgentEmitter(), groups); + } + + private static string EmitClass(ServiceAgentEmitter emitter, params ManifestGroup[] groups) { var manifest = new EndpointManifest { Groups = groups.ToList() }; - var files = new ServiceAgentEmitter().Emit(manifest, "Cnblogs.Vip.ServiceAgent"); + var files = emitter.Emit(manifest, "Cnblogs.Vip.ServiceAgent"); return files.First(f => f.FileName.EndsWith("Service.cs", StringComparison.Ordinal) && !f.FileName.StartsWith("I", StringComparison.Ordinal) && !f.IsExtensionsFile).Content; } @@ -333,4 +342,204 @@ public void Emit_SameCommandMappedTwice_EmitsSinglePoco() Assert.Single(files, f => f.FileName.EndsWith("Payload.cs", StringComparison.Ordinal)); Assert.Single(files, f => f.FileName == "CreateBlogPayload.cs"); } + + [Fact] + public void Emit_SingleApiVersion_KeepsUnsuffixedNameAndStampsVersion() + { + // Arrange — every endpoint declares v2: one un-suffixed agent, and the {version:apiVersion} token stamped + // with the declared version (not the historical default "1"). + var emitter = new ServiceAgentEmitter(); + + // Act + var files = emitter.Emit( + new EndpointManifest + { + Groups = + [ + new ManifestGroup + { + Name = "Accusation", + ErrorType = Error("AccusationError"), + Endpoints = + [ + Query( + "/api/v{version:apiVersion}/accusations", + "ListAccusationQuery", + ResponseShape.PagedList, + new() { Namespace = "Cnblogs.Architecture.Ddd.Infrastructure.Abstractions", Name = "PagedList", GenericArguments = [Dto("AccusationDto")] }, + [QueryParam("ReporterId", Sys("Guid"), nullable: true)], + apiVersions: ["2"]) + ] + } + ] + }, + "Cnblogs.Report.ServiceAgent"); + + // Assert — the un-suffixed pair exists and the URL uses the declared version. + Assert.Contains(files, f => f.FileName == "IAccusationService.cs"); + Assert.Contains(files, f => f.FileName == "AccusationService.cs"); + var cls = files.First(f => f.FileName == "AccusationService.cs").Content; + Assert.Contains("\"/api/v2/accusations\"", cls); + Assert.DoesNotContain("/api/v1/", cls); + } + + [Fact] + public void Emit_MultipleApiVersions_SplitsIntoSuffixedGroups() + { + // Arrange — the same group spans v1 and v2 endpoints (the default, no-option path). + var v1Endpoint = Query( + "/api/v{version:apiVersion}/accusations/{id:int}", + "GetAccusationQuery", + ResponseShape.Item, + Dto("AccusationDto"), + [Route("Id", Sys("Int32"), "id")], + apiVersions: ["1"]); + var v2Endpoint = Query( + "/api/v{version:apiVersion}/accusations", + "ListAccusationQuery", + ResponseShape.PagedList, + new() { Namespace = "Cnblogs.Architecture.Ddd.Infrastructure.Abstractions", Name = "PagedList", GenericArguments = [Dto("AccusationDto")] }, + [QueryParam("ReporterId", Sys("Guid"), nullable: true)], + apiVersions: ["2"]); + + // Act + var files = new ServiceAgentEmitter().Emit( + new EndpointManifest + { + Groups = + [ + new ManifestGroup { Name = "Accusation", ErrorType = Error("AccusationError"), Endpoints = [v1Endpoint, v2Endpoint] } + ] + }, + "Cnblogs.Report.ServiceAgent"); + + // Assert — one suffixed pair per version, each stamped with its own URL version. + Assert.Contains(files, f => f.FileName == "IAccusationV1Service.cs"); + Assert.Contains(files, f => f.FileName == "IAccusationV2Service.cs"); + var v1Cls = files.First(f => f.FileName == "AccusationV1Service.cs").Content; + var v2Cls = files.First(f => f.FileName == "AccusationV2Service.cs").Content; + Assert.Contains("\"/api/v1/accusations/{id}\"", v1Cls); + Assert.Contains("\"/api/v2/accusations\"", v2Cls); + + // The DI extensions register each split agent under its suffixed name. + var ext = files.First(f => f.IsExtensionsFile).Content; + Assert.Contains("AddAccusationV1Service(", ext); + Assert.Contains("AddAccusationV2Service(", ext); + } + + [Fact] + public void Emit_RequestedApiVersion_FiltersEndpointsAndKeepsUnsuffixedName() + { + // Arrange — --api-version 2: only v2 endpoints are emitted, as one un-suffixed IAccusationService. + var v1Endpoint = Query( + "/api/v{version:apiVersion}/accusations/{id:int}", + "GetAccusationQuery", + ResponseShape.Item, + Dto("AccusationDto"), + [Route("Id", Sys("Int32"), "id")], + apiVersions: ["1"]); + var v2Endpoint = Query( + "/api/v{version:apiVersion}/accusations", + "ListAccusationQuery", + ResponseShape.PagedList, + new() { Namespace = "Cnblogs.Architecture.Ddd.Infrastructure.Abstractions", Name = "PagedList", GenericArguments = [Dto("AccusationDto")] }, + [QueryParam("ReporterId", Sys("Guid"), nullable: true)], + apiVersions: ["2"]); + var emitter = new ServiceAgentEmitter { RequestedApiVersion = "2" }; + + // Act + var files = emitter.Emit( + new EndpointManifest + { + Groups = + [ + new ManifestGroup { Name = "Accusation", ErrorType = Error("AccusationError"), Endpoints = [v1Endpoint, v2Endpoint] } + ] + }, + "Cnblogs.Report.ServiceAgent"); + + // Assert — the un-suffixed pair (per the requirement), the v1 endpoint dropped with a warning. + Assert.Contains(files, f => f.FileName == "IAccusationService.cs"); + Assert.DoesNotContain(files, f => f.FileName.EndsWith("V1Service.cs", StringComparison.Ordinal)); + var cls = files.First(f => f.FileName == "AccusationService.cs").Content; + Assert.Contains("\"/api/v2/accusations\"", cls); + Assert.DoesNotContain("GetAccusationAsync", cls); + Assert.Contains( + emitter.Diagnostics, + d => d.Contains("not declaring API version '2'", StringComparison.Ordinal)); + } + + [Fact] + public void Emit_RequestedApiVersion_KeepsUnversionedEndpoints() + { + // Arrange — an endpoint without any version metadata (e.g. a plain MapQuery outside versioned groups) + // stays in the requested-version output; it falls back to the emitter default stamp. + var unversioned = Query("/api/v{version:apiVersion}/health", "GetHealthQuery", ResponseShape.Item, Sys("String"), [], apiVersions: []); + var v1Endpoint = Query( + "/api/v{version:apiVersion}/accusations/{id:int}", + "GetAccusationQuery", + ResponseShape.Item, + Dto("AccusationDto"), + [Route("Id", Sys("Int32"), "id")], + apiVersions: ["1"]); + var emitter = new ServiceAgentEmitter { RequestedApiVersion = "2" }; + + // Act + var files = emitter.Emit( + new EndpointManifest + { + Groups = + [ + new ManifestGroup { Name = "Accusation", ErrorType = Error("AccusationError"), Endpoints = [v1Endpoint, unversioned] } + ] + }, + "Cnblogs.Report.ServiceAgent"); + + // Assert + var cls = files.First(f => f.FileName == "AccusationService.cs").Content; + Assert.Contains("GetHealthAsync", cls); + } + + [Fact] + public void Emit_RequestedApiVersion_NormalizesMinorVersions() + { + // Arrange — "2.0" and "2" are the same Asp.Versioning version; --api-version 2 must match a declared "2.0". + var endpoint = Query( + "/api/v{version:apiVersion}/accusations", + "ListAccusationQuery", + ResponseShape.PagedList, + new() { Namespace = "Cnblogs.Architecture.Ddd.Infrastructure.Abstractions", Name = "PagedList", GenericArguments = [Dto("AccusationDto")] }, + [], + apiVersions: ["2.0"]); + var emitter = new ServiceAgentEmitter { RequestedApiVersion = "2" }; + + // Act + var files = emitter.Emit( + new EndpointManifest { Groups = [new ManifestGroup { Name = "Accusation", Endpoints = [endpoint] }] }, + "Cnblogs.Report.ServiceAgent"); + + // Assert — the endpoint survives the filter. + var cls = files.First(f => f.FileName == "AccusationService.cs").Content; + Assert.Contains("ListAccusationAsync", cls); + } + + [Fact] + public void Emit_NoVersionMetadata_DefaultsToVersionOneStamp() + { + // Arrange — no endpoint declares versions (plain route groups without HasApiVersion): the legacy behavior + // (stamp "1") is preserved. + var cls = EmitClass( + new ManifestGroup + { + Name = "Vip", + ErrorType = Error("VipError"), + Endpoints = + [ + Query("/api/v{version:apiVersion}/products/{id:int}", "GetVipProductQuery", ResponseShape.Item, Dto("VipProductDto"), [Route("Id", Sys("Int32"), "id")]) + ] + }); + + // Assert + Assert.Contains("\"/api/v1/products/{id}\"", cls); + } }