Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,12 @@
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.10" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />

<!-- ServiceDefaults -->
<!-- Observability + outgoing HTTP -->
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.8.0" />
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.8.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.16.0" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.16.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.16.0" />
Expand Down
4 changes: 1 addition & 3 deletions ProductReviews.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@
<Folder Name="/src/">
<Project Path="src/ProductReviews.Api/ProductReviews.Api.csproj" />
<Project Path="src/ProductReviews.AppHost/ProductReviews.AppHost.csproj" />
<Project Path="src/ProductReviews.Domain/ProductReviews.Domain.csproj" />
<Project Path="src/ProductReviews.ServiceDefaults/ProductReviews.ServiceDefaults.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ProductReviews.Api.IntegrationTests/ProductReviews.Api.IntegrationTests.csproj" />
<Project Path="tests/ProductReviews.Domain.Tests/ProductReviews.Domain.Tests.csproj" />
<Project Path="tests/ProductReviews.Api.UnitTests/ProductReviews.Api.UnitTests.csproj" />
</Folder>
</Solution>
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using ProductReviews.Domain.Catalog;
using StrongTypes;

namespace ProductReviews.Api.Features.Catalog;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>ViewerReviewId is the signed-in viewer's own review of this product, when they have one.</summary>
public sealed record ProductDetailModel(Product Product, Guid? ViewerReviewId);
Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
using ProductReviews.Domain.Reviews;
using ProductReviews.Api.Features.Reviews;

namespace ProductReviews.Domain.Catalog;
namespace ProductReviews.Api.Features.Catalog;

/// <summary>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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
using System.Text.Json.Serialization;
using StrongTypes;

namespace ProductReviews.Domain.Reviews;
namespace ProductReviews.Api.Features.Reviews;

/// <summary>A whole-star product rating, 1–5. Declared with the same three-line
/// recipe the library's own numeric wrappers use: <c>[NumericWrapper]</c> plus a
Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
using StrongTypes;

namespace ProductReviews.Domain.Reviews;
namespace ProductReviews.Api.Features.Reviews;

/// <summary>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.</summary>
public sealed record ReviewAuthor(Guid AuthorId, NonEmptyString DisplayName);
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System.Text.Json.Serialization;
using ProductReviews.Domain.Reviews;
using StrongTypes;

namespace ProductReviews.Api.Features.Reviews;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace ProductReviews.Domain.Reviews;
namespace ProductReviews.Api.Features.Reviews;

public enum ReviewSort
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace ProductReviews.Domain.Votes;
namespace ProductReviews.Api.Features.Votes;

/// <summary>One reviewer's helpful/not-helpful verdict on one review.
/// The composite key (ReviewId, VoterId) makes "one vote per reviewer per review" structural.</summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
using ProductReviews.Domain.Reviews;
using ProductReviews.Api.Features.Reviews;

namespace ProductReviews.Domain.Votes;
namespace ProductReviews.Api.Features.Votes;

/// <summary>Proof-of-loading aggregate: a review together with all its votes, so the
/// helpfulness score can be recomputed without trusting a lazily-loaded navigation.
Expand Down
1 change: 0 additions & 1 deletion src/ProductReviews.Api/Features/Votes/VoteContracts.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using ProductReviews.Domain.Votes;

namespace ProductReviews.Api.Features.Votes;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace ProductReviews.Domain.Votes;
namespace ProductReviews.Api.Features.Votes;

/// <summary>The state a voter sees after a vote operation: the review's recomputed
/// score and their own current vote (null = no vote).</summary>
Expand Down
1 change: 0 additions & 1 deletion src/ProductReviews.Api/Features/Votes/VotesController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions src/ProductReviews.Api/Infrastructure/AppVersion.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System.Reflection;

namespace ProductReviews.Api.Infrastructure;

public static class AppVersion
{
// The SDK embeds the git commit as the SourceRevisionId ("<version>+<sha>") when
// building from a checkout; a deploy gate reads it back out of /health.
public static readonly string InformationalVersion =
Assembly.GetEntryAssembly()
?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion ?? "unknown";

public static readonly string CommitHash =
InformationalVersion.Contains('+')
? InformationalVersion[(InformationalVersion.IndexOf('+') + 1)..]
: InformationalVersion;
}
3 changes: 2 additions & 1 deletion src/ProductReviews.Api/Infrastructure/Authentication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions src/ProductReviews.Api/Infrastructure/CommitHashHealthCheck.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;

namespace ProductReviews.Api.Infrastructure;

internal sealed class CommitHashHealthCheck : IHealthCheck
{
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
=> Task.FromResult(new HealthCheckResult(HealthStatus.Healthy, data: new Dictionary<string, object>
{
["commit"] = AppVersion.CommitHash,
}));
}
4 changes: 2 additions & 2 deletions src/ProductReviews.Api/Infrastructure/CurrentUser.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Maps the authenticated principal to the domain's <see cref="ReviewAuthor"/>.
/// AuthorId is the SHA-256 of the OIDC <c>sub</c> claim (first 16 bytes as a Guid), so the
/// database stays Guid-keyed whatever shape the identity provider's ids have (ADR-0005).</summary>
/// database stays Guid-keyed whatever shape the identity provider's ids have (ADR-0003).</summary>
public static class CurrentUser
{
public static ReviewAuthor Author(this ClaimsPrincipal user)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
Expand Down
2 changes: 1 addition & 1 deletion src/ProductReviews.Api/Infrastructure/ErrorHandling.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
namespace ProductReviews.Api.Infrastructure;

/// <summary>Unhandled exceptions become RFC 7807 responses. Business failures never get
/// here — they travel as Result error enums and are mapped by controllers (ADR-0003).</summary>
/// here — they travel as Result error enums and are mapped by controllers (ADR-0001).</summary>
public static class ErrorHandling
{
public static void Configure(WebApplicationBuilder builder)
Expand Down
45 changes: 45 additions & 0 deletions src/ProductReviews.Api/Infrastructure/Health.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using ProductReviews.Api.Persistence;

namespace ProductReviews.Api.Infrastructure;

/// <summary>/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.</summary>
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<CommitHashHealthCheck>("version", tags: ["live"])
.AddDbContextCheck<ReviewsDbContext>("database")
.AddCheck<ZitadelHealthCheck>("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,
});
}

// "<status> <commit>" — 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);
}
}
34 changes: 30 additions & 4 deletions src/ProductReviews.Api/Infrastructure/Observability.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,37 @@
using ProductReviews.Domain.Persistence;
using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;

namespace ProductReviews.Api.Infrastructure;

/// <summary>API-specific health checks. Tracing, metrics, and the /health endpoints
/// themselves come from ServiceDefaults.</summary>
/// <summary>OpenTelemetry logging, metrics, and tracing, exported over OTLP when an
/// endpoint is configured (the Aspire dashboard sets one).</summary>
public static class Observability
{
public static void Configure(WebApplicationBuilder builder)
=> builder.Services.AddHealthChecks().AddDbContextCheck<ReviewsDbContext>("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();
}
}
}
Loading