diff --git a/Directory.Packages.props b/Directory.Packages.props index ee0eeed..000682c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,12 +20,12 @@ + - + - diff --git a/ProductReviews.slnx b/ProductReviews.slnx index 4b02a2a..42a6a7c 100644 --- a/ProductReviews.slnx +++ b/ProductReviews.slnx @@ -2,11 +2,9 @@ - - - + diff --git a/src/ProductReviews.Api/Features/Catalog/CatalogContracts.cs b/src/ProductReviews.Api/Features/Catalog/CatalogContracts.cs index 42dcc46..eab8f16 100644 --- a/src/ProductReviews.Api/Features/Catalog/CatalogContracts.cs +++ b/src/ProductReviews.Api/Features/Catalog/CatalogContracts.cs @@ -1,4 +1,3 @@ -using ProductReviews.Domain.Catalog; using StrongTypes; namespace ProductReviews.Api.Features.Catalog; diff --git a/src/ProductReviews.Api/Features/Catalog/CatalogController.cs b/src/ProductReviews.Api/Features/Catalog/CatalogController.cs index 5bf19c3..c707d37 100644 --- a/src/ProductReviews.Api/Features/Catalog/CatalogController.cs +++ b/src/ProductReviews.Api/Features/Catalog/CatalogController.cs @@ -1,7 +1,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using ProductReviews.Api.Infrastructure; -using ProductReviews.Domain.Catalog; using StrongTypes; namespace ProductReviews.Api.Features.Catalog; diff --git a/src/ProductReviews.Domain/Catalog/GetCatalog.cs b/src/ProductReviews.Api/Features/Catalog/GetCatalog.cs similarity index 80% rename from src/ProductReviews.Domain/Catalog/GetCatalog.cs rename to src/ProductReviews.Api/Features/Catalog/GetCatalog.cs index b5bc1bd..23203d7 100644 --- a/src/ProductReviews.Domain/Catalog/GetCatalog.cs +++ b/src/ProductReviews.Api/Features/Catalog/GetCatalog.cs @@ -1,7 +1,7 @@ using Microsoft.EntityFrameworkCore; -using ProductReviews.Domain.Persistence; +using ProductReviews.Api.Persistence; -namespace ProductReviews.Domain.Catalog; +namespace ProductReviews.Api.Features.Catalog; public sealed class GetCatalogHandler(ReviewsDbContext dbContext) { diff --git a/src/ProductReviews.Domain/Catalog/GetProductDetail.cs b/src/ProductReviews.Api/Features/Catalog/GetProductDetail.cs similarity index 93% rename from src/ProductReviews.Domain/Catalog/GetProductDetail.cs rename to src/ProductReviews.Api/Features/Catalog/GetProductDetail.cs index 823bc20..5476052 100644 --- a/src/ProductReviews.Domain/Catalog/GetProductDetail.cs +++ b/src/ProductReviews.Api/Features/Catalog/GetProductDetail.cs @@ -1,8 +1,8 @@ using Microsoft.EntityFrameworkCore; -using ProductReviews.Domain.Persistence; +using ProductReviews.Api.Persistence; using StrongTypes; -namespace ProductReviews.Domain.Catalog; +namespace ProductReviews.Api.Features.Catalog; /// ViewerReviewId is the signed-in viewer's own review of this product, when they have one. public sealed record ProductDetailModel(Product Product, Guid? ViewerReviewId); diff --git a/src/ProductReviews.Domain/Catalog/Product.cs b/src/ProductReviews.Api/Features/Catalog/Product.cs similarity index 94% rename from src/ProductReviews.Domain/Catalog/Product.cs rename to src/ProductReviews.Api/Features/Catalog/Product.cs index 7bee746..b118dec 100644 --- a/src/ProductReviews.Domain/Catalog/Product.cs +++ b/src/ProductReviews.Api/Features/Catalog/Product.cs @@ -1,7 +1,7 @@ -using ProductReviews.Domain.Reviews; +using ProductReviews.Api.Features.Reviews; using StrongTypes; -namespace ProductReviews.Domain.Catalog; +namespace ProductReviews.Api.Features.Catalog; public sealed class Product { diff --git a/src/ProductReviews.Domain/Catalog/ProductWithReviews.cs b/src/ProductReviews.Api/Features/Catalog/ProductWithReviews.cs similarity index 86% rename from src/ProductReviews.Domain/Catalog/ProductWithReviews.cs rename to src/ProductReviews.Api/Features/Catalog/ProductWithReviews.cs index 3a8449d..42cd440 100644 --- a/src/ProductReviews.Domain/Catalog/ProductWithReviews.cs +++ b/src/ProductReviews.Api/Features/Catalog/ProductWithReviews.cs @@ -1,6 +1,6 @@ -using ProductReviews.Domain.Reviews; +using ProductReviews.Api.Features.Reviews; -namespace ProductReviews.Domain.Catalog; +namespace ProductReviews.Api.Features.Catalog; /// Proof-of-loading aggregate: it can only be constructed with the product's /// reviews in hand, so code holding one never meets an unloaded navigation. diff --git a/src/ProductReviews.Domain/Reviews/DeleteReview.cs b/src/ProductReviews.Api/Features/Reviews/DeleteReview.cs similarity index 93% rename from src/ProductReviews.Domain/Reviews/DeleteReview.cs rename to src/ProductReviews.Api/Features/Reviews/DeleteReview.cs index 74343b3..110cfda 100644 --- a/src/ProductReviews.Domain/Reviews/DeleteReview.cs +++ b/src/ProductReviews.Api/Features/Reviews/DeleteReview.cs @@ -1,7 +1,7 @@ using Microsoft.EntityFrameworkCore; -using ProductReviews.Domain.Persistence; +using ProductReviews.Api.Persistence; -namespace ProductReviews.Domain.Reviews; +namespace ProductReviews.Api.Features.Reviews; public enum DeleteReviewError { diff --git a/src/ProductReviews.Domain/Reviews/EditReview.cs b/src/ProductReviews.Api/Features/Reviews/EditReview.cs similarity index 95% rename from src/ProductReviews.Domain/Reviews/EditReview.cs rename to src/ProductReviews.Api/Features/Reviews/EditReview.cs index d18f866..8a07382 100644 --- a/src/ProductReviews.Domain/Reviews/EditReview.cs +++ b/src/ProductReviews.Api/Features/Reviews/EditReview.cs @@ -1,8 +1,8 @@ using Microsoft.EntityFrameworkCore; -using ProductReviews.Domain.Persistence; +using ProductReviews.Api.Persistence; using StrongTypes; -namespace ProductReviews.Domain.Reviews; +namespace ProductReviews.Api.Features.Reviews; public enum EditReviewError { diff --git a/src/ProductReviews.Domain/Reviews/GetReviewsPage.cs b/src/ProductReviews.Api/Features/Reviews/GetReviewsPage.cs similarity index 97% rename from src/ProductReviews.Domain/Reviews/GetReviewsPage.cs rename to src/ProductReviews.Api/Features/Reviews/GetReviewsPage.cs index cc64511..b2333ba 100644 --- a/src/ProductReviews.Domain/Reviews/GetReviewsPage.cs +++ b/src/ProductReviews.Api/Features/Reviews/GetReviewsPage.cs @@ -1,8 +1,8 @@ using Microsoft.EntityFrameworkCore; -using ProductReviews.Domain.Persistence; +using ProductReviews.Api.Persistence; using StrongTypes; -namespace ProductReviews.Domain.Reviews; +namespace ProductReviews.Api.Features.Reviews; /// A review as one specific viewer sees it: with whether it is their own /// and which way they voted on it. Constructed only by the page query, so both diff --git a/src/ProductReviews.Domain/Reviews/Rating.cs b/src/ProductReviews.Api/Features/Reviews/Rating.cs similarity index 98% rename from src/ProductReviews.Domain/Reviews/Rating.cs rename to src/ProductReviews.Api/Features/Reviews/Rating.cs index ffc0af9..079522f 100644 --- a/src/ProductReviews.Domain/Reviews/Rating.cs +++ b/src/ProductReviews.Api/Features/Reviews/Rating.cs @@ -2,7 +2,7 @@ using System.Text.Json.Serialization; using StrongTypes; -namespace ProductReviews.Domain.Reviews; +namespace ProductReviews.Api.Features.Reviews; /// A whole-star product rating, 1–5. Declared with the same three-line /// recipe the library's own numeric wrappers use: [NumericWrapper] plus a diff --git a/src/ProductReviews.Domain/Reviews/Review.cs b/src/ProductReviews.Api/Features/Reviews/Review.cs similarity index 97% rename from src/ProductReviews.Domain/Reviews/Review.cs rename to src/ProductReviews.Api/Features/Reviews/Review.cs index 7024881..7256446 100644 --- a/src/ProductReviews.Domain/Reviews/Review.cs +++ b/src/ProductReviews.Api/Features/Reviews/Review.cs @@ -1,7 +1,7 @@ -using ProductReviews.Domain.Votes; +using ProductReviews.Api.Features.Votes; using StrongTypes; -namespace ProductReviews.Domain.Reviews; +namespace ProductReviews.Api.Features.Reviews; public sealed class Review { diff --git a/src/ProductReviews.Domain/Reviews/ReviewAuthor.cs b/src/ProductReviews.Api/Features/Reviews/ReviewAuthor.cs similarity index 69% rename from src/ProductReviews.Domain/Reviews/ReviewAuthor.cs rename to src/ProductReviews.Api/Features/Reviews/ReviewAuthor.cs index fa4800c..247c697 100644 --- a/src/ProductReviews.Domain/Reviews/ReviewAuthor.cs +++ b/src/ProductReviews.Api/Features/Reviews/ReviewAuthor.cs @@ -1,8 +1,8 @@ using StrongTypes; -namespace ProductReviews.Domain.Reviews; +namespace ProductReviews.Api.Features.Reviews; /// The acting reviewer, as the domain sees them: an opaque stable id -/// (derived from the identity provider's subject, see ADR-0005) and the display +/// (derived from the identity provider's subject, see ADR-0003) and the display /// name snapshotted onto anything they write. public sealed record ReviewAuthor(Guid AuthorId, NonEmptyString DisplayName); diff --git a/src/ProductReviews.Api/Features/Reviews/ReviewContracts.cs b/src/ProductReviews.Api/Features/Reviews/ReviewContracts.cs index 46e9fa8..6114cf9 100644 --- a/src/ProductReviews.Api/Features/Reviews/ReviewContracts.cs +++ b/src/ProductReviews.Api/Features/Reviews/ReviewContracts.cs @@ -1,5 +1,4 @@ using System.Text.Json.Serialization; -using ProductReviews.Domain.Reviews; using StrongTypes; namespace ProductReviews.Api.Features.Reviews; diff --git a/src/ProductReviews.Domain/Reviews/ReviewSort.cs b/src/ProductReviews.Api/Features/Reviews/ReviewSort.cs similarity index 66% rename from src/ProductReviews.Domain/Reviews/ReviewSort.cs rename to src/ProductReviews.Api/Features/Reviews/ReviewSort.cs index 0669670..0219c5e 100644 --- a/src/ProductReviews.Domain/Reviews/ReviewSort.cs +++ b/src/ProductReviews.Api/Features/Reviews/ReviewSort.cs @@ -1,4 +1,4 @@ -namespace ProductReviews.Domain.Reviews; +namespace ProductReviews.Api.Features.Reviews; public enum ReviewSort { diff --git a/src/ProductReviews.Api/Features/Reviews/ReviewsController.cs b/src/ProductReviews.Api/Features/Reviews/ReviewsController.cs index ace115a..2c88998 100644 --- a/src/ProductReviews.Api/Features/Reviews/ReviewsController.cs +++ b/src/ProductReviews.Api/Features/Reviews/ReviewsController.cs @@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using ProductReviews.Api.Infrastructure; -using ProductReviews.Domain.Reviews; using StrongTypes; namespace ProductReviews.Api.Features.Reviews; diff --git a/src/ProductReviews.Domain/Reviews/SubmitReview.cs b/src/ProductReviews.Api/Features/Reviews/SubmitReview.cs similarity index 94% rename from src/ProductReviews.Domain/Reviews/SubmitReview.cs rename to src/ProductReviews.Api/Features/Reviews/SubmitReview.cs index c5c1282..558b5aa 100644 --- a/src/ProductReviews.Domain/Reviews/SubmitReview.cs +++ b/src/ProductReviews.Api/Features/Reviews/SubmitReview.cs @@ -1,7 +1,7 @@ -using ProductReviews.Domain.Persistence; +using ProductReviews.Api.Persistence; using StrongTypes; -namespace ProductReviews.Domain.Reviews; +namespace ProductReviews.Api.Features.Reviews; public enum SubmitReviewError { diff --git a/src/ProductReviews.Domain/Votes/CastVote.cs b/src/ProductReviews.Api/Features/Votes/CastVote.cs similarity index 94% rename from src/ProductReviews.Domain/Votes/CastVote.cs rename to src/ProductReviews.Api/Features/Votes/CastVote.cs index 0789feb..327caf3 100644 --- a/src/ProductReviews.Domain/Votes/CastVote.cs +++ b/src/ProductReviews.Api/Features/Votes/CastVote.cs @@ -1,7 +1,7 @@ -using ProductReviews.Domain.Persistence; +using ProductReviews.Api.Persistence; using StrongTypes; -namespace ProductReviews.Domain.Votes; +namespace ProductReviews.Api.Features.Votes; public enum CastVoteError { diff --git a/src/ProductReviews.Domain/Votes/RemoveVote.cs b/src/ProductReviews.Api/Features/Votes/RemoveVote.cs similarity index 93% rename from src/ProductReviews.Domain/Votes/RemoveVote.cs rename to src/ProductReviews.Api/Features/Votes/RemoveVote.cs index 4aecd73..1cdef4e 100644 --- a/src/ProductReviews.Domain/Votes/RemoveVote.cs +++ b/src/ProductReviews.Api/Features/Votes/RemoveVote.cs @@ -1,7 +1,7 @@ -using ProductReviews.Domain.Persistence; +using ProductReviews.Api.Persistence; using StrongTypes; -namespace ProductReviews.Domain.Votes; +namespace ProductReviews.Api.Features.Votes; public enum RemoveVoteError { diff --git a/src/ProductReviews.Domain/Votes/ReviewVote.cs b/src/ProductReviews.Api/Features/Votes/ReviewVote.cs similarity index 94% rename from src/ProductReviews.Domain/Votes/ReviewVote.cs rename to src/ProductReviews.Api/Features/Votes/ReviewVote.cs index 9d75882..8e2c61a 100644 --- a/src/ProductReviews.Domain/Votes/ReviewVote.cs +++ b/src/ProductReviews.Api/Features/Votes/ReviewVote.cs @@ -1,4 +1,4 @@ -namespace ProductReviews.Domain.Votes; +namespace ProductReviews.Api.Features.Votes; /// One reviewer's helpful/not-helpful verdict on one review. /// The composite key (ReviewId, VoterId) makes "one vote per reviewer per review" structural. diff --git a/src/ProductReviews.Domain/Votes/ReviewWithVotes.cs b/src/ProductReviews.Api/Features/Votes/ReviewWithVotes.cs similarity index 85% rename from src/ProductReviews.Domain/Votes/ReviewWithVotes.cs rename to src/ProductReviews.Api/Features/Votes/ReviewWithVotes.cs index b396cf2..f9d91f8 100644 --- a/src/ProductReviews.Domain/Votes/ReviewWithVotes.cs +++ b/src/ProductReviews.Api/Features/Votes/ReviewWithVotes.cs @@ -1,6 +1,6 @@ -using ProductReviews.Domain.Reviews; +using ProductReviews.Api.Features.Reviews; -namespace ProductReviews.Domain.Votes; +namespace ProductReviews.Api.Features.Votes; /// Proof-of-loading aggregate: a review together with all its votes, so the /// helpfulness score can be recomputed without trusting a lazily-loaded navigation. diff --git a/src/ProductReviews.Api/Features/Votes/VoteContracts.cs b/src/ProductReviews.Api/Features/Votes/VoteContracts.cs index f45fc0f..db9ed4a 100644 --- a/src/ProductReviews.Api/Features/Votes/VoteContracts.cs +++ b/src/ProductReviews.Api/Features/Votes/VoteContracts.cs @@ -1,4 +1,3 @@ -using ProductReviews.Domain.Votes; namespace ProductReviews.Api.Features.Votes; diff --git a/src/ProductReviews.Domain/Votes/VoteSummary.cs b/src/ProductReviews.Api/Features/Votes/VoteSummary.cs similarity index 82% rename from src/ProductReviews.Domain/Votes/VoteSummary.cs rename to src/ProductReviews.Api/Features/Votes/VoteSummary.cs index decf477..49a446b 100644 --- a/src/ProductReviews.Domain/Votes/VoteSummary.cs +++ b/src/ProductReviews.Api/Features/Votes/VoteSummary.cs @@ -1,4 +1,4 @@ -namespace ProductReviews.Domain.Votes; +namespace ProductReviews.Api.Features.Votes; /// The state a voter sees after a vote operation: the review's recomputed /// score and their own current vote (null = no vote). diff --git a/src/ProductReviews.Api/Features/Votes/VotesController.cs b/src/ProductReviews.Api/Features/Votes/VotesController.cs index 9cf781a..05018cf 100644 --- a/src/ProductReviews.Api/Features/Votes/VotesController.cs +++ b/src/ProductReviews.Api/Features/Votes/VotesController.cs @@ -2,7 +2,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using ProductReviews.Api.Infrastructure; -using ProductReviews.Domain.Votes; using StrongTypes; namespace ProductReviews.Api.Features.Votes; diff --git a/src/ProductReviews.Api/Infrastructure/AppVersion.cs b/src/ProductReviews.Api/Infrastructure/AppVersion.cs new file mode 100644 index 0000000..f8f3a2f --- /dev/null +++ b/src/ProductReviews.Api/Infrastructure/AppVersion.cs @@ -0,0 +1,18 @@ +using System.Reflection; + +namespace ProductReviews.Api.Infrastructure; + +public static class AppVersion +{ + // The SDK embeds the git commit as the SourceRevisionId ("+") when + // building from a checkout; a deploy gate reads it back out of /health. + public static readonly string InformationalVersion = + Assembly.GetEntryAssembly() + ?.GetCustomAttribute() + ?.InformationalVersion ?? "unknown"; + + public static readonly string CommitHash = + InformationalVersion.Contains('+') + ? InformationalVersion[(InformationalVersion.IndexOf('+') + 1)..] + : InformationalVersion; +} diff --git a/src/ProductReviews.Api/Infrastructure/Authentication.cs b/src/ProductReviews.Api/Infrastructure/Authentication.cs index 7c7b800..9511814 100644 --- a/src/ProductReviews.Api/Infrastructure/Authentication.cs +++ b/src/ProductReviews.Api/Infrastructure/Authentication.cs @@ -20,7 +20,8 @@ public static void Configure(WebApplicationBuilder builder) var audience = builder.Configuration["Oidc:Audience"]; builder.Services.AddMemoryCache(); - builder.Services.AddHttpClient(UserinfoClientName); + // Per-client resilience, not ConfigureHttpClientDefaults — nothing blanket-global. + builder.Services.AddHttpClient(UserinfoClientName).AddStandardResilienceHandler(); builder.Services .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) diff --git a/src/ProductReviews.Api/Infrastructure/CommitHashHealthCheck.cs b/src/ProductReviews.Api/Infrastructure/CommitHashHealthCheck.cs new file mode 100644 index 0000000..0119f61 --- /dev/null +++ b/src/ProductReviews.Api/Infrastructure/CommitHashHealthCheck.cs @@ -0,0 +1,12 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace ProductReviews.Api.Infrastructure; + +internal sealed class CommitHashHealthCheck : IHealthCheck +{ + public Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + => Task.FromResult(new HealthCheckResult(HealthStatus.Healthy, data: new Dictionary + { + ["commit"] = AppVersion.CommitHash, + })); +} diff --git a/src/ProductReviews.Api/Infrastructure/CurrentUser.cs b/src/ProductReviews.Api/Infrastructure/CurrentUser.cs index 722815d..5944271 100644 --- a/src/ProductReviews.Api/Infrastructure/CurrentUser.cs +++ b/src/ProductReviews.Api/Infrastructure/CurrentUser.cs @@ -1,14 +1,14 @@ using System.Security.Claims; using System.Security.Cryptography; using System.Text; -using ProductReviews.Domain.Reviews; +using ProductReviews.Api.Features.Reviews; using StrongTypes; namespace ProductReviews.Api.Infrastructure; /// Maps the authenticated principal to the domain's . /// AuthorId is the SHA-256 of the OIDC sub claim (first 16 bytes as a Guid), so the -/// database stays Guid-keyed whatever shape the identity provider's ids have (ADR-0005). +/// database stays Guid-keyed whatever shape the identity provider's ids have (ADR-0003). public static class CurrentUser { public static ReviewAuthor Author(this ClaimsPrincipal user) diff --git a/src/ProductReviews.Domain/DomainServices.cs b/src/ProductReviews.Api/Infrastructure/DomainServices.cs similarity index 78% rename from src/ProductReviews.Domain/DomainServices.cs rename to src/ProductReviews.Api/Infrastructure/DomainServices.cs index ff159b3..edc34fe 100644 --- a/src/ProductReviews.Domain/DomainServices.cs +++ b/src/ProductReviews.Api/Infrastructure/DomainServices.cs @@ -1,10 +1,9 @@ -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using ProductReviews.Domain.Catalog; -using ProductReviews.Domain.Reviews; -using ProductReviews.Domain.Votes; +using ProductReviews.Api.Features.Catalog; +using ProductReviews.Api.Features.Reviews; +using ProductReviews.Api.Features.Votes; -namespace ProductReviews.Domain; +namespace ProductReviews.Api.Infrastructure; public static class DomainServices { diff --git a/src/ProductReviews.Api/Infrastructure/ErrorHandling.cs b/src/ProductReviews.Api/Infrastructure/ErrorHandling.cs index ceab5d3..a1d858d 100644 --- a/src/ProductReviews.Api/Infrastructure/ErrorHandling.cs +++ b/src/ProductReviews.Api/Infrastructure/ErrorHandling.cs @@ -1,7 +1,7 @@ namespace ProductReviews.Api.Infrastructure; /// Unhandled exceptions become RFC 7807 responses. Business failures never get -/// here — they travel as Result error enums and are mapped by controllers (ADR-0003). +/// here — they travel as Result error enums and are mapped by controllers (ADR-0001). public static class ErrorHandling { public static void Configure(WebApplicationBuilder builder) diff --git a/src/ProductReviews.Api/Infrastructure/Health.cs b/src/ProductReviews.Api/Infrastructure/Health.cs new file mode 100644 index 0000000..809aab7 --- /dev/null +++ b/src/ProductReviews.Api/Infrastructure/Health.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using ProductReviews.Api.Persistence; + +namespace ProductReviews.Api.Infrastructure; + +/// /alive = process up + which build, no dependencies — a shared Postgres or +/// Zitadel outage must never read as a dead process. /health = full readiness, every +/// dependency included. Both bodies carry the running commit for a deploy gate to grep. +public static class Health +{ + public const string HealthEndpointPath = "/health"; + public const string AlivenessEndpointPath = "/alive"; + + public static void Configure(WebApplicationBuilder builder) + { + builder.Services.AddHttpClient(ZitadelHealthCheck.ClientName, client => client.Timeout = TimeSpan.FromSeconds(5)); + + builder.Services.AddHealthChecks() + .AddCheck("version", tags: ["live"]) + .AddDbContextCheck("database") + .AddCheck("zitadel"); + } + + public static void Use(WebApplication app) + { + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = registration => registration.Tags.Contains("live"), + ResponseWriter = WriteStatusAndCommitAsync, + }); + + app.MapHealthChecks(HealthEndpointPath, new HealthCheckOptions + { + ResponseWriter = WriteStatusAndCommitAsync, + }); + } + + // " " — greppable by a deploy gate, no per-check detail to leak. + private static Task WriteStatusAndCommitAsync(HttpContext context, HealthReport report) + { + context.Response.ContentType = "text/plain"; + return context.Response.WriteAsync($"{report.Status} {AppVersion.CommitHash}", context.RequestAborted); + } +} diff --git a/src/ProductReviews.Api/Infrastructure/Observability.cs b/src/ProductReviews.Api/Infrastructure/Observability.cs index 4797000..dc214af 100644 --- a/src/ProductReviews.Api/Infrastructure/Observability.cs +++ b/src/ProductReviews.Api/Infrastructure/Observability.cs @@ -1,11 +1,37 @@ -using ProductReviews.Domain.Persistence; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; namespace ProductReviews.Api.Infrastructure; -/// API-specific health checks. Tracing, metrics, and the /health endpoints -/// themselves come from ServiceDefaults. +/// OpenTelemetry logging, metrics, and tracing, exported over OTLP when an +/// endpoint is configured (the Aspire dashboard sets one). public static class Observability { public static void Configure(WebApplicationBuilder builder) - => builder.Services.AddHealthChecks().AddDbContextCheck("database"); + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => metrics + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation()) + .WithTracing(tracing => tracing + .AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(options => + options.Filter = context => + !context.Request.Path.StartsWithSegments(Health.HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(Health.AlivenessEndpointPath)) + .AddHttpClientInstrumentation()); + + if (!string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"])) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + } } diff --git a/src/ProductReviews.Api/Infrastructure/OpenApi.cs b/src/ProductReviews.Api/Infrastructure/OpenApi.cs index 6e2be9e..79a24f1 100644 --- a/src/ProductReviews.Api/Infrastructure/OpenApi.cs +++ b/src/ProductReviews.Api/Infrastructure/OpenApi.cs @@ -1,12 +1,12 @@ using Microsoft.OpenApi; -using ProductReviews.Domain.Reviews; +using ProductReviews.Api.Features.Reviews; using StrongTypes.OpenApi.Swashbuckle; namespace ProductReviews.Api.Infrastructure; /// Swashbuckle with AddStrongTypes(), so the document carries the real /// constraints (email format, minLength, exclusiveMinimum). The generated TypeScript -/// client is built from this document — it is the frontend contract (ADR-0004). +/// client is built from this document — it is the frontend contract (ADR-0002). public static class OpenApi { public static void Configure(WebApplicationBuilder builder) diff --git a/src/ProductReviews.Api/Infrastructure/Persistence.cs b/src/ProductReviews.Api/Infrastructure/Persistence.cs index ec319e8..0899de3 100644 --- a/src/ProductReviews.Api/Infrastructure/Persistence.cs +++ b/src/ProductReviews.Api/Infrastructure/Persistence.cs @@ -1,6 +1,6 @@ using Microsoft.EntityFrameworkCore; -using ProductReviews.Domain.Persistence; -using ProductReviews.Domain.Persistence.Seeding; +using ProductReviews.Api.Persistence; +using ProductReviews.Api.Persistence.Seeding; using StrongTypes.EfCore; namespace ProductReviews.Api.Infrastructure; @@ -13,9 +13,11 @@ public static class Persistence public static void Configure(WebApplicationBuilder builder) => builder.AddNpgsqlDbContext( DatabaseResourceName, + // The explicit "database" check in Health.cs is the one source of DB health. + configureSettings: settings => settings.DisableHealthChecks = true, configureDbContextOptions: options => options.UseStrongTypes()); - /// Development-only convenience (ADR-0006): production applies migrations + /// Development-only convenience (ADR-0004): production applies migrations /// from the deploy pipeline and never seeds. public static async Task MigrateAndSeedAsync(WebApplication app) { diff --git a/src/ProductReviews.Api/Infrastructure/ZitadelHealthCheck.cs b/src/ProductReviews.Api/Infrastructure/ZitadelHealthCheck.cs new file mode 100644 index 0000000..d1f3ac5 --- /dev/null +++ b/src/ProductReviews.Api/Infrastructure/ZitadelHealthCheck.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace ProductReviews.Api.Infrastructure; + +/// Probes the OIDC discovery document so a dead or misconfigured Zitadel surfaces +/// in /health rather than as sign-in failures. Degraded, not Unhealthy: reads keep working +/// without the identity provider — only sign-in and writes are affected. +internal sealed class ZitadelHealthCheck(IHttpClientFactory httpClientFactory, IConfiguration configuration) : IHealthCheck +{ + public const string ClientName = "zitadel-health"; + + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + var authority = configuration["Oidc:Authority"]?.TrimEnd('/'); + if (string.IsNullOrEmpty(authority)) + { + return HealthCheckResult.Degraded("Oidc:Authority is not configured; sign-in is unavailable."); + } + + try + { + var client = httpClientFactory.CreateClient(ClientName); + using var response = await client.GetAsync($"{authority}/.well-known/openid-configuration", cancellationToken); + return response.IsSuccessStatusCode + ? HealthCheckResult.Healthy() + : HealthCheckResult.Degraded($"OIDC discovery returned {(int)response.StatusCode}."); + } + catch (Exception exception) + { + return HealthCheckResult.Degraded("OIDC authority unreachable.", exception); + } + } +} diff --git a/src/ProductReviews.Domain/Persistence/CompleteQueries.cs b/src/ProductReviews.Api/Persistence/CompleteQueries.cs similarity index 92% rename from src/ProductReviews.Domain/Persistence/CompleteQueries.cs rename to src/ProductReviews.Api/Persistence/CompleteQueries.cs index 4f77977..2009be2 100644 --- a/src/ProductReviews.Domain/Persistence/CompleteQueries.cs +++ b/src/ProductReviews.Api/Persistence/CompleteQueries.cs @@ -1,9 +1,9 @@ using Microsoft.EntityFrameworkCore; -using ProductReviews.Domain.Catalog; -using ProductReviews.Domain.Votes; +using ProductReviews.Api.Features.Catalog; +using ProductReviews.Api.Features.Votes; using StrongTypes; -namespace ProductReviews.Domain.Persistence; +namespace ProductReviews.Api.Persistence; /// The single source of truth for what a "complete" aggregate loads. /// Each method pairs an Include chain with the record that proves it ran. diff --git a/src/ProductReviews.Domain/Persistence/Configurations/ProductConfiguration.cs b/src/ProductReviews.Api/Persistence/Configurations/ProductConfiguration.cs similarity index 87% rename from src/ProductReviews.Domain/Persistence/Configurations/ProductConfiguration.cs rename to src/ProductReviews.Api/Persistence/Configurations/ProductConfiguration.cs index 7482018..36e77c4 100644 --- a/src/ProductReviews.Domain/Persistence/Configurations/ProductConfiguration.cs +++ b/src/ProductReviews.Api/Persistence/Configurations/ProductConfiguration.cs @@ -1,8 +1,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -using ProductReviews.Domain.Catalog; +using ProductReviews.Api.Features.Catalog; -namespace ProductReviews.Domain.Persistence.Configurations; +namespace ProductReviews.Api.Persistence.Configurations; internal sealed class ProductConfiguration : IEntityTypeConfiguration { diff --git a/src/ProductReviews.Domain/Persistence/Configurations/ReviewConfiguration.cs b/src/ProductReviews.Api/Persistence/Configurations/ReviewConfiguration.cs similarity index 92% rename from src/ProductReviews.Domain/Persistence/Configurations/ReviewConfiguration.cs rename to src/ProductReviews.Api/Persistence/Configurations/ReviewConfiguration.cs index 4818fa7..5a73fae 100644 --- a/src/ProductReviews.Domain/Persistence/Configurations/ReviewConfiguration.cs +++ b/src/ProductReviews.Api/Persistence/Configurations/ReviewConfiguration.cs @@ -1,9 +1,9 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -using ProductReviews.Domain.Catalog; -using ProductReviews.Domain.Reviews; +using ProductReviews.Api.Features.Catalog; +using ProductReviews.Api.Features.Reviews; -namespace ProductReviews.Domain.Persistence.Configurations; +namespace ProductReviews.Api.Persistence.Configurations; internal sealed class ReviewConfiguration : IEntityTypeConfiguration { diff --git a/src/ProductReviews.Domain/Persistence/Configurations/ReviewVoteConfiguration.cs b/src/ProductReviews.Api/Persistence/Configurations/ReviewVoteConfiguration.cs similarity index 78% rename from src/ProductReviews.Domain/Persistence/Configurations/ReviewVoteConfiguration.cs rename to src/ProductReviews.Api/Persistence/Configurations/ReviewVoteConfiguration.cs index 4674243..d59f782 100644 --- a/src/ProductReviews.Domain/Persistence/Configurations/ReviewVoteConfiguration.cs +++ b/src/ProductReviews.Api/Persistence/Configurations/ReviewVoteConfiguration.cs @@ -1,9 +1,9 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -using ProductReviews.Domain.Reviews; -using ProductReviews.Domain.Votes; +using ProductReviews.Api.Features.Reviews; +using ProductReviews.Api.Features.Votes; -namespace ProductReviews.Domain.Persistence.Configurations; +namespace ProductReviews.Api.Persistence.Configurations; internal sealed class ReviewVoteConfiguration : IEntityTypeConfiguration { diff --git a/src/ProductReviews.Domain/Persistence/Migrations/.editorconfig b/src/ProductReviews.Api/Persistence/Migrations/.editorconfig similarity index 100% rename from src/ProductReviews.Domain/Persistence/Migrations/.editorconfig rename to src/ProductReviews.Api/Persistence/Migrations/.editorconfig diff --git a/src/ProductReviews.Domain/Persistence/Migrations/20260715061407_InitialCreate.Designer.cs b/src/ProductReviews.Api/Persistence/Migrations/20260715061407_InitialCreate.Designer.cs similarity index 87% rename from src/ProductReviews.Domain/Persistence/Migrations/20260715061407_InitialCreate.Designer.cs rename to src/ProductReviews.Api/Persistence/Migrations/20260715061407_InitialCreate.Designer.cs index a32e785..bccfb81 100644 --- a/src/ProductReviews.Domain/Persistence/Migrations/20260715061407_InitialCreate.Designer.cs +++ b/src/ProductReviews.Api/Persistence/Migrations/20260715061407_InitialCreate.Designer.cs @@ -1,15 +1,15 @@ -// +// using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using ProductReviews.Domain.Persistence; +using ProductReviews.Api.Persistence; #nullable disable -namespace ProductReviews.Domain.Persistence.Migrations +namespace ProductReviews.Api.Persistence.Migrations { [DbContext(typeof(ReviewsDbContext))] [Migration("20260715061407_InitialCreate")] @@ -25,7 +25,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("ProductReviews.Domain.Catalog.Product", b => + modelBuilder.Entity("ProductReviews.Api.Features.Catalog.Product", b => { b.Property("Id") .HasColumnType("bigint"); @@ -66,7 +66,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("Products"); }); - modelBuilder.Entity("ProductReviews.Domain.Reviews.Review", b => + modelBuilder.Entity("ProductReviews.Api.Features.Reviews.Review", b => { b.Property("Id") .HasColumnType("uuid"); @@ -130,7 +130,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) }); }); - modelBuilder.Entity("ProductReviews.Domain.Votes.ReviewVote", b => + modelBuilder.Entity("ProductReviews.Api.Features.Votes.ReviewVote", b => { b.Property("ReviewId") .HasColumnType("uuid"); @@ -149,30 +149,30 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("ReviewVotes"); }); - modelBuilder.Entity("ProductReviews.Domain.Reviews.Review", b => + modelBuilder.Entity("ProductReviews.Api.Features.Reviews.Review", b => { - b.HasOne("ProductReviews.Domain.Catalog.Product", null) + b.HasOne("ProductReviews.Api.Features.Catalog.Product", null) .WithMany("Reviews") .HasForeignKey("ProductId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); - modelBuilder.Entity("ProductReviews.Domain.Votes.ReviewVote", b => + modelBuilder.Entity("ProductReviews.Api.Features.Votes.ReviewVote", b => { - b.HasOne("ProductReviews.Domain.Reviews.Review", null) + b.HasOne("ProductReviews.Api.Features.Reviews.Review", null) .WithMany("Votes") .HasForeignKey("ReviewId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); - modelBuilder.Entity("ProductReviews.Domain.Catalog.Product", b => + modelBuilder.Entity("ProductReviews.Api.Features.Catalog.Product", b => { b.Navigation("Reviews"); }); - modelBuilder.Entity("ProductReviews.Domain.Reviews.Review", b => + modelBuilder.Entity("ProductReviews.Api.Features.Reviews.Review", b => { b.Navigation("Votes"); }); diff --git a/src/ProductReviews.Domain/Persistence/Migrations/20260715061407_InitialCreate.cs b/src/ProductReviews.Api/Persistence/Migrations/20260715061407_InitialCreate.cs similarity index 98% rename from src/ProductReviews.Domain/Persistence/Migrations/20260715061407_InitialCreate.cs rename to src/ProductReviews.Api/Persistence/Migrations/20260715061407_InitialCreate.cs index 2276383..0ddc05c 100644 --- a/src/ProductReviews.Domain/Persistence/Migrations/20260715061407_InitialCreate.cs +++ b/src/ProductReviews.Api/Persistence/Migrations/20260715061407_InitialCreate.cs @@ -1,9 +1,9 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable -namespace ProductReviews.Domain.Persistence.Migrations +namespace ProductReviews.Api.Persistence.Migrations { /// public partial class InitialCreate : Migration diff --git a/src/ProductReviews.Domain/Persistence/Migrations/ReviewsDbContextModelSnapshot.cs b/src/ProductReviews.Api/Persistence/Migrations/ReviewsDbContextModelSnapshot.cs similarity index 86% rename from src/ProductReviews.Domain/Persistence/Migrations/ReviewsDbContextModelSnapshot.cs rename to src/ProductReviews.Api/Persistence/Migrations/ReviewsDbContextModelSnapshot.cs index 44ccd5a..52827d5 100644 --- a/src/ProductReviews.Domain/Persistence/Migrations/ReviewsDbContextModelSnapshot.cs +++ b/src/ProductReviews.Api/Persistence/Migrations/ReviewsDbContextModelSnapshot.cs @@ -1,14 +1,14 @@ -// +// using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using ProductReviews.Domain.Persistence; +using ProductReviews.Api.Persistence; #nullable disable -namespace ProductReviews.Domain.Persistence.Migrations +namespace ProductReviews.Api.Persistence.Migrations { [DbContext(typeof(ReviewsDbContext))] partial class ReviewsDbContextModelSnapshot : ModelSnapshot @@ -22,7 +22,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("ProductReviews.Domain.Catalog.Product", b => + modelBuilder.Entity("ProductReviews.Api.Features.Catalog.Product", b => { b.Property("Id") .HasColumnType("bigint"); @@ -63,7 +63,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Products"); }); - modelBuilder.Entity("ProductReviews.Domain.Reviews.Review", b => + modelBuilder.Entity("ProductReviews.Api.Features.Reviews.Review", b => { b.Property("Id") .HasColumnType("uuid"); @@ -127,7 +127,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); - modelBuilder.Entity("ProductReviews.Domain.Votes.ReviewVote", b => + modelBuilder.Entity("ProductReviews.Api.Features.Votes.ReviewVote", b => { b.Property("ReviewId") .HasColumnType("uuid"); @@ -146,30 +146,30 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ReviewVotes"); }); - modelBuilder.Entity("ProductReviews.Domain.Reviews.Review", b => + modelBuilder.Entity("ProductReviews.Api.Features.Reviews.Review", b => { - b.HasOne("ProductReviews.Domain.Catalog.Product", null) + b.HasOne("ProductReviews.Api.Features.Catalog.Product", null) .WithMany("Reviews") .HasForeignKey("ProductId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); - modelBuilder.Entity("ProductReviews.Domain.Votes.ReviewVote", b => + modelBuilder.Entity("ProductReviews.Api.Features.Votes.ReviewVote", b => { - b.HasOne("ProductReviews.Domain.Reviews.Review", null) + b.HasOne("ProductReviews.Api.Features.Reviews.Review", null) .WithMany("Votes") .HasForeignKey("ReviewId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); - modelBuilder.Entity("ProductReviews.Domain.Catalog.Product", b => + modelBuilder.Entity("ProductReviews.Api.Features.Catalog.Product", b => { b.Navigation("Reviews"); }); - modelBuilder.Entity("ProductReviews.Domain.Reviews.Review", b => + modelBuilder.Entity("ProductReviews.Api.Features.Reviews.Review", b => { b.Navigation("Votes"); }); diff --git a/src/ProductReviews.Domain/Persistence/ReviewsDbContext.cs b/src/ProductReviews.Api/Persistence/ReviewsDbContext.cs similarity index 84% rename from src/ProductReviews.Domain/Persistence/ReviewsDbContext.cs rename to src/ProductReviews.Api/Persistence/ReviewsDbContext.cs index 48e24bb..96d8b48 100644 --- a/src/ProductReviews.Domain/Persistence/ReviewsDbContext.cs +++ b/src/ProductReviews.Api/Persistence/ReviewsDbContext.cs @@ -1,10 +1,10 @@ using Microsoft.EntityFrameworkCore; -using ProductReviews.Domain.Catalog; -using ProductReviews.Domain.Reviews; -using ProductReviews.Domain.Votes; +using ProductReviews.Api.Features.Catalog; +using ProductReviews.Api.Features.Reviews; +using ProductReviews.Api.Features.Votes; using StrongTypes.EfCore; -namespace ProductReviews.Domain.Persistence; +namespace ProductReviews.Api.Persistence; public sealed class ReviewsDbContext(DbContextOptions options) : DbContext(options) { diff --git a/src/ProductReviews.Domain/Persistence/ReviewsDbContextDesignTimeFactory.cs b/src/ProductReviews.Api/Persistence/ReviewsDbContextDesignTimeFactory.cs similarity index 93% rename from src/ProductReviews.Domain/Persistence/ReviewsDbContextDesignTimeFactory.cs rename to src/ProductReviews.Api/Persistence/ReviewsDbContextDesignTimeFactory.cs index 2f8a097..d473949 100644 --- a/src/ProductReviews.Domain/Persistence/ReviewsDbContextDesignTimeFactory.cs +++ b/src/ProductReviews.Api/Persistence/ReviewsDbContextDesignTimeFactory.cs @@ -2,7 +2,7 @@ using Microsoft.EntityFrameworkCore.Design; using StrongTypes.EfCore; -namespace ProductReviews.Domain.Persistence; +namespace ProductReviews.Api.Persistence; /// For `dotnet ef` only. The connection string is never opened during /// `migrations add` — it just satisfies the provider registration. diff --git a/src/ProductReviews.Domain/Persistence/Seeding/DemoCatalog.cs b/src/ProductReviews.Api/Persistence/Seeding/DemoCatalog.cs similarity index 99% rename from src/ProductReviews.Domain/Persistence/Seeding/DemoCatalog.cs rename to src/ProductReviews.Api/Persistence/Seeding/DemoCatalog.cs index 3b82592..a1aecdc 100644 --- a/src/ProductReviews.Domain/Persistence/Seeding/DemoCatalog.cs +++ b/src/ProductReviews.Api/Persistence/Seeding/DemoCatalog.cs @@ -1,4 +1,4 @@ -namespace ProductReviews.Domain.Persistence.Seeding; +namespace ProductReviews.Api.Persistence.Seeding; internal sealed record SeedAuthor(Guid Id, string Name); diff --git a/src/ProductReviews.Domain/Persistence/Seeding/DemoDataSeeder.cs b/src/ProductReviews.Api/Persistence/Seeding/DemoDataSeeder.cs similarity index 92% rename from src/ProductReviews.Domain/Persistence/Seeding/DemoDataSeeder.cs rename to src/ProductReviews.Api/Persistence/Seeding/DemoDataSeeder.cs index 43ab4ed..677e83c 100644 --- a/src/ProductReviews.Domain/Persistence/Seeding/DemoDataSeeder.cs +++ b/src/ProductReviews.Api/Persistence/Seeding/DemoDataSeeder.cs @@ -1,12 +1,12 @@ using Microsoft.EntityFrameworkCore; -using ProductReviews.Domain.Catalog; -using ProductReviews.Domain.Reviews; -using ProductReviews.Domain.Votes; +using ProductReviews.Api.Features.Catalog; +using ProductReviews.Api.Features.Reviews; +using ProductReviews.Api.Features.Votes; using StrongTypes; -namespace ProductReviews.Domain.Persistence.Seeding; +namespace ProductReviews.Api.Persistence.Seeding; -/// Idempotent demo seeding (ADR-0006): runs after migrations, does nothing +/// Idempotent demo seeding (ADR-0004): runs after migrations, does nothing /// once products exist, and builds everything through the domain entities so seed /// data satisfies the same invariants as user input. public static class DemoDataSeeder diff --git a/src/ProductReviews.Api/ProductReviews.Api.csproj b/src/ProductReviews.Api/ProductReviews.Api.csproj index c54f982..2a65088 100644 --- a/src/ProductReviews.Api/ProductReviews.Api.csproj +++ b/src/ProductReviews.Api/ProductReviews.Api.csproj @@ -2,18 +2,27 @@ + + - + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + - - - - - diff --git a/src/ProductReviews.Api/Program.cs b/src/ProductReviews.Api/Program.cs index 94a0aa2..3ce218c 100644 --- a/src/ProductReviews.Api/Program.cs +++ b/src/ProductReviews.Api/Program.cs @@ -1,18 +1,15 @@ using ProductReviews.Api.Infrastructure; -using ProductReviews.Domain; -using ProductReviews.ServiceDefaults; using StrongTypes.AspNetCore; var builder = WebApplication.CreateBuilder(args); -builder.AddServiceDefaults(); - +Observability.Configure(builder); +Health.Configure(builder); Persistence.Configure(builder); Authentication.Configure(builder); RateLimits.Configure(builder); ErrorHandling.Configure(builder); OpenApi.Configure(builder); -Observability.Configure(builder); builder.Services.AddControllers(); builder.Services.AddStrongTypes(options => options.JsonErrorKeyCasing = JsonErrorKeyCasing.CamelCase); @@ -24,8 +21,8 @@ Authentication.Use(app); RateLimits.Use(app); OpenApi.Use(app); +Health.Use(app); -app.MapDefaultEndpoints(); app.MapControllers(); await Persistence.MigrateAndSeedAsync(app); diff --git a/src/ProductReviews.AppHost/AppHost.cs b/src/ProductReviews.AppHost/AppHost.cs index 21f22da..bb8e07b 100644 --- a/src/ProductReviews.AppHost/AppHost.cs +++ b/src/ProductReviews.AppHost/AppHost.cs @@ -20,7 +20,7 @@ // "zitadeldb", not "zitadel" — the container resource already claims that name. var zitadelDatabase = postgres.AddDatabase("zitadeldb"); -// --- Identity provider: self-hosted Zitadel (ADR-0005) ----------------------- +// --- Identity provider: self-hosted Zitadel (ADR-0003) ----------------------- // Zitadel writes its first-instance machine-user PATs into this bind-mounted dir on first // init; the AppHost reads them to drive provisioning. Gitignored. E2E gets a unique temp // dir: its Postgres is throwaway, so a PAT left over from a previous run would belong to a @@ -86,7 +86,7 @@ } }); -// --- Frontend: Vue 3 + Vite SPA (ADR-0008) ------------------------------------- +// --- Frontend: Vue 3 + Vite SPA (ADR-0006) ------------------------------------- // AddNpmApp only runs the script — install once when node_modules is absent and gate on it. var frontendDirectory = Path.Combine(repositoryRoot, "frontend"); IResourceBuilder? frontendInstall = null; diff --git a/src/ProductReviews.AppHost/Zitadel/ZitadelHosting.cs b/src/ProductReviews.AppHost/Zitadel/ZitadelHosting.cs index bdacff3..7142a91 100644 --- a/src/ProductReviews.AppHost/Zitadel/ZitadelHosting.cs +++ b/src/ProductReviews.AppHost/Zitadel/ZitadelHosting.cs @@ -1,6 +1,6 @@ namespace ProductReviews.AppHost.Zitadel; -/// The self-hosted Zitadel containers (ADR-0005): the core (API + Console) and the +/// The self-hosted Zitadel containers (ADR-0003): the core (API + Console) and the /// separate Login V2 app the core redirects browsers to for the hosted sign-in page. internal static class ZitadelHosting { diff --git a/src/ProductReviews.Domain/ProductReviews.Domain.csproj b/src/ProductReviews.Domain/ProductReviews.Domain.csproj deleted file mode 100644 index ec7702e..0000000 --- a/src/ProductReviews.Domain/ProductReviews.Domain.csproj +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers - - - - - diff --git a/src/ProductReviews.ServiceDefaults/Extensions.cs b/src/ProductReviews.ServiceDefaults/Extensions.cs deleted file mode 100644 index 436f8a8..0000000 --- a/src/ProductReviews.ServiceDefaults/Extensions.cs +++ /dev/null @@ -1,106 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Diagnostics.HealthChecks; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using OpenTelemetry; -using OpenTelemetry.Metrics; -using OpenTelemetry.Trace; - -namespace ProductReviews.ServiceDefaults; - -// Common Aspire service wiring: service discovery, resilience, health checks, -// and OpenTelemetry. Deliberately in its own namespace — consumers opt in with -// an explicit using instead of the extensions leaking through a framework -// namespace. -public static class Extensions -{ - private const string HealthEndpointPath = "/health"; - private const string AlivenessEndpointPath = "/alive"; - - public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - builder.ConfigureOpenTelemetry(); - - builder.AddDefaultHealthChecks(); - - builder.Services.AddServiceDiscovery(); - - builder.Services.ConfigureHttpClientDefaults(http => - { - http.AddStandardResilienceHandler(); - http.AddServiceDiscovery(); - }); - - return builder; - } - - public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - builder.Logging.AddOpenTelemetry(logging => - { - logging.IncludeFormattedMessage = true; - logging.IncludeScopes = true; - }); - - builder.Services.AddOpenTelemetry() - .WithMetrics(metrics => - { - metrics.AddAspNetCoreInstrumentation() - .AddHttpClientInstrumentation() - .AddRuntimeInstrumentation(); - }) - .WithTracing(tracing => - { - tracing.AddSource(builder.Environment.ApplicationName) - .AddAspNetCoreInstrumentation(tracing => - tracing.Filter = context => - !context.Request.Path.StartsWithSegments(HealthEndpointPath) - && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) - ) - .AddHttpClientInstrumentation(); - }); - - builder.AddOpenTelemetryExporters(); - - return builder; - } - - private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); - - if (useOtlpExporter) - { - builder.Services.AddOpenTelemetry().UseOtlpExporter(); - } - - return builder; - } - - public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - builder.Services.AddHealthChecks() - .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); - - return builder; - } - - public static WebApplication MapDefaultEndpoints(this WebApplication app) - { - // Health endpoints are Development-only; exposing them publicly has - // security implications — see https://aka.ms/dotnet/aspire/healthchecks. - if (app.Environment.IsDevelopment()) - { - app.MapHealthChecks(HealthEndpointPath); - - app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions - { - Predicate = r => r.Tags.Contains("live") - }); - } - - return app; - } -} diff --git a/src/ProductReviews.ServiceDefaults/ProductReviews.ServiceDefaults.csproj b/src/ProductReviews.ServiceDefaults/ProductReviews.ServiceDefaults.csproj deleted file mode 100644 index c567fa5..0000000 --- a/src/ProductReviews.ServiceDefaults/ProductReviews.ServiceDefaults.csproj +++ /dev/null @@ -1,19 +0,0 @@ - - - - true - - - - - - - - - - - - - - - diff --git a/tests/ProductReviews.Api.IntegrationTests/HealthApiTests.cs b/tests/ProductReviews.Api.IntegrationTests/HealthApiTests.cs new file mode 100644 index 0000000..fc6fe42 --- /dev/null +++ b/tests/ProductReviews.Api.IntegrationTests/HealthApiTests.cs @@ -0,0 +1,33 @@ +using System.Net; +using Xunit; + +namespace ProductReviews.Api.IntegrationTests; + +[Collection(nameof(SharedApiCollection))] +public sealed class HealthApiTests(SharedApiContext context) +{ + [Fact] + public async Task Alive_ReportsHealthyAndTheRunningCommit_WithoutTouchingDependencies() + { + var response = await context.AnonymousClient.GetAsync("/alive", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + // "Healthy ": liveness carries the build identity but no dependency state — + // Zitadel is not configured here, yet the process still reports alive. + Assert.StartsWith("Healthy ", body); + Assert.False(string.IsNullOrWhiteSpace(body["Healthy ".Length..])); + } + + [Fact] + public async Task Health_ReportsDegradedWhenTheIdentityProviderIsMissing_ButTheDatabaseCheckPasses() + { + var response = await context.AnonymousClient.GetAsync("/health", TestContext.Current.CancellationToken); + + // Degraded (not Unhealthy): the database is reachable and reads keep working; only + // sign-in is unavailable without an OIDC authority. Degraded still returns 200. + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.StartsWith("Degraded ", body); + } +} diff --git a/tests/ProductReviews.Api.IntegrationTests/SharedApiContext.cs b/tests/ProductReviews.Api.IntegrationTests/SharedApiContext.cs index e0724ae..4e9c89d 100644 --- a/tests/ProductReviews.Api.IntegrationTests/SharedApiContext.cs +++ b/tests/ProductReviews.Api.IntegrationTests/SharedApiContext.cs @@ -13,7 +13,7 @@ public sealed class ReviewsApiFactory(string connectionString) : WebApplicationF { protected override void ConfigureWebHost(IWebHostBuilder builder) { - // Development runs the real migrate + seed path at startup (ADR-0006) — + // Development runs the real migrate + seed path at startup (ADR-0004) — // the tests exercise it instead of preparing the schema themselves. builder.UseEnvironment("Development"); builder.UseSetting("ConnectionStrings:productreviews", connectionString); @@ -34,7 +34,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) } } -/// One Postgres container + one API host for the whole run (ADR-0007); +/// One Postgres container + one API host for the whole run (ADR-0005); /// tests isolate through unique authors/products, never by resetting the database. public sealed class SharedApiContext : IAsyncLifetime { diff --git a/tests/ProductReviews.Api.IntegrationTests/TestTokens.cs b/tests/ProductReviews.Api.IntegrationTests/TestTokens.cs index 3e179e0..402660c 100644 --- a/tests/ProductReviews.Api.IntegrationTests/TestTokens.cs +++ b/tests/ProductReviews.Api.IntegrationTests/TestTokens.cs @@ -5,7 +5,7 @@ namespace ProductReviews.Api.IntegrationTests; /// Mints JWTs against a test-owned symmetric key. The API's real JwtBearer -/// pipeline validates them (ADR-0007) — only the trust anchor is swapped, not the code path. +/// pipeline validates them (ADR-0005) — only the trust anchor is swapped, not the code path. public static class TestTokens { public const string Issuer = "https://tests.productreviews.local"; diff --git a/tests/ProductReviews.Domain.Tests/DomainGenerators.cs b/tests/ProductReviews.Api.UnitTests/DomainGenerators.cs similarity index 74% rename from tests/ProductReviews.Domain.Tests/DomainGenerators.cs rename to tests/ProductReviews.Api.UnitTests/DomainGenerators.cs index 416643e..082a056 100644 --- a/tests/ProductReviews.Domain.Tests/DomainGenerators.cs +++ b/tests/ProductReviews.Api.UnitTests/DomainGenerators.cs @@ -1,20 +1,21 @@ using FsCheck; using FsCheck.Fluent; -using ProductReviews.Domain.Reviews; using StrongTypes; // FsCheck ships its own NonEmptyString test wrapper; ours is the StrongTypes one. using NonEmptyString = StrongTypes.NonEmptyString; +// Aliased because the arbitrary property below is itself named Rating. +using RatingValue = ProductReviews.Api.Features.Reviews.Rating; -namespace ProductReviews.Domain.Tests; +namespace ProductReviews.Api.UnitTests; /// Project-specific arbitraries. Register together with the library's: /// [Properties(Arbitrary = [typeof(DomainGenerators), typeof(Generators)])] /// (StrongTypes.FsCheck.Generators covers NonEmptyString, Maybe, and friends). public static class DomainGenerators { - public static Arbitrary Rating { get; } = - Gen.Choose(Reviews.Rating.MinimumStars, Reviews.Rating.MaximumStars) - .Select(stars => Reviews.Rating.Create(stars)) + public static Arbitrary Rating { get; } = + Gen.Choose(RatingValue.MinimumStars, RatingValue.MaximumStars) + .Select(stars => RatingValue.Create(stars)) .ToArbitrary(); /// The three-state edit parameter: ~30% skip (null), ~20% clear (None), ~50% set. diff --git a/tests/ProductReviews.Domain.Tests/ProductRatingSummaryTests.cs b/tests/ProductReviews.Api.UnitTests/ProductRatingSummaryTests.cs similarity index 92% rename from tests/ProductReviews.Domain.Tests/ProductRatingSummaryTests.cs rename to tests/ProductReviews.Api.UnitTests/ProductRatingSummaryTests.cs index c06dc5c..e774e15 100644 --- a/tests/ProductReviews.Domain.Tests/ProductRatingSummaryTests.cs +++ b/tests/ProductReviews.Api.UnitTests/ProductRatingSummaryTests.cs @@ -1,11 +1,11 @@ using FsCheck.Xunit; -using ProductReviews.Domain.Catalog; -using ProductReviews.Domain.Reviews; +using ProductReviews.Api.Features.Catalog; +using ProductReviews.Api.Features.Reviews; using StrongTypes; using StrongTypes.FsCheck; using Xunit; -namespace ProductReviews.Domain.Tests; +namespace ProductReviews.Api.UnitTests; [Properties(Arbitrary = [typeof(DomainGenerators), typeof(Generators)])] public sealed class ProductRatingSummaryTests diff --git a/tests/ProductReviews.Domain.Tests/ProductReviews.Domain.Tests.csproj b/tests/ProductReviews.Api.UnitTests/ProductReviews.Api.UnitTests.csproj similarity index 83% rename from tests/ProductReviews.Domain.Tests/ProductReviews.Domain.Tests.csproj rename to tests/ProductReviews.Api.UnitTests/ProductReviews.Api.UnitTests.csproj index a63d511..a1a71e3 100644 --- a/tests/ProductReviews.Domain.Tests/ProductReviews.Domain.Tests.csproj +++ b/tests/ProductReviews.Api.UnitTests/ProductReviews.Api.UnitTests.csproj @@ -14,7 +14,7 @@ - + diff --git a/tests/ProductReviews.Domain.Tests/RatingTests.cs b/tests/ProductReviews.Api.UnitTests/RatingTests.cs similarity index 91% rename from tests/ProductReviews.Domain.Tests/RatingTests.cs rename to tests/ProductReviews.Api.UnitTests/RatingTests.cs index 5a66da1..1604ae8 100644 --- a/tests/ProductReviews.Domain.Tests/RatingTests.cs +++ b/tests/ProductReviews.Api.UnitTests/RatingTests.cs @@ -1,9 +1,9 @@ using FsCheck.Xunit; -using ProductReviews.Domain.Reviews; +using ProductReviews.Api.Features.Reviews; using StrongTypes.FsCheck; using Xunit; -namespace ProductReviews.Domain.Tests; +namespace ProductReviews.Api.UnitTests; [Properties(Arbitrary = [typeof(DomainGenerators), typeof(Generators)])] public sealed class RatingTests diff --git a/tests/ProductReviews.Domain.Tests/ReviewEditTests.cs b/tests/ProductReviews.Api.UnitTests/ReviewEditTests.cs similarity index 96% rename from tests/ProductReviews.Domain.Tests/ReviewEditTests.cs rename to tests/ProductReviews.Api.UnitTests/ReviewEditTests.cs index fee4567..8519a38 100644 --- a/tests/ProductReviews.Domain.Tests/ReviewEditTests.cs +++ b/tests/ProductReviews.Api.UnitTests/ReviewEditTests.cs @@ -1,10 +1,10 @@ using FsCheck.Xunit; -using ProductReviews.Domain.Reviews; +using ProductReviews.Api.Features.Reviews; using StrongTypes; using StrongTypes.FsCheck; using Xunit; -namespace ProductReviews.Domain.Tests; +namespace ProductReviews.Api.UnitTests; /// The PATCH contract of Review.ApplyEdit: null leaves a field unchanged; /// for the clearable optionals, Maybe.None clears and Maybe.Some replaces. diff --git a/tests/ProductReviews.Domain.Tests/ReviewTestData.cs b/tests/ProductReviews.Api.UnitTests/ReviewTestData.cs similarity index 89% rename from tests/ProductReviews.Domain.Tests/ReviewTestData.cs rename to tests/ProductReviews.Api.UnitTests/ReviewTestData.cs index 31d4a11..c3dc5f9 100644 --- a/tests/ProductReviews.Domain.Tests/ReviewTestData.cs +++ b/tests/ProductReviews.Api.UnitTests/ReviewTestData.cs @@ -1,7 +1,7 @@ -using ProductReviews.Domain.Reviews; +using ProductReviews.Api.Features.Reviews; using StrongTypes; -namespace ProductReviews.Domain.Tests; +namespace ProductReviews.Api.UnitTests; internal static class ReviewTestData { diff --git a/tests/ProductReviews.Domain.Tests/ScoreTests.cs b/tests/ProductReviews.Api.UnitTests/ScoreTests.cs similarity index 91% rename from tests/ProductReviews.Domain.Tests/ScoreTests.cs rename to tests/ProductReviews.Api.UnitTests/ScoreTests.cs index 17e5966..13f416d 100644 --- a/tests/ProductReviews.Domain.Tests/ScoreTests.cs +++ b/tests/ProductReviews.Api.UnitTests/ScoreTests.cs @@ -1,8 +1,8 @@ using FsCheck.Xunit; -using ProductReviews.Domain.Votes; +using ProductReviews.Api.Features.Votes; using Xunit; -namespace ProductReviews.Domain.Tests; +namespace ProductReviews.Api.UnitTests; public sealed class ScoreTests {