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
210 changes: 210 additions & 0 deletions src/SimPle.Api/Controllers/GamesController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
using System.IdentityModel.Tokens.Jwt;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using SimPle.Api.Models;
using SimPle.Application.Games.DTOs;
using SimPle.Application.Games.Services;
using SimPle.Shared.Common;
using Swashbuckle.AspNetCore.Annotations;

namespace SimPle.Api.Controllers;

/// <summary>
/// Public catalog reads (list/detail/featured) are anonymous, auth-independent, and cache-headered per spec
/// deviation D1 (<c>Vary: Cookie</c>, not <c>Vary: Authorization</c> — this app authenticates via cookie only,
/// see <c>ProfileController.cs:112-115</c>). Favorites are authenticated, ownership-scoped from the JWT
/// <c>sub</c> claim only (never a request body id), and never cached.
/// </summary>
[ApiController]
[Route("api/games")]
[Produces("application/json")]
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status500InternalServerError)]
public sealed class GamesController : ControllerBase
{
private readonly IGamesService _games;

public GamesController(IGamesService games)
{
_games = games;
}

// ── Public catalog reads ────────────────────────────────────────────────

[HttpGet]
[EnableRateLimiting("catalog-read")]
[SwaggerOperation(Summary = "List/search the game catalog (keyset cursor paged)",
OperationId = "Games_List", Tags = new[] { "Games" })]
[ProducesResponseType(typeof(CursorPage<GameCatalogDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status304NotModified)]
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)]
public async Task<IActionResult> List(
[FromQuery] string? query,
[FromQuery] string[]? category,
[FromQuery] string[]? tag,
[FromQuery] string[]? mode,
[FromQuery] string[]? lifecycle,
[FromQuery] string? sort,
[FromQuery] int limit = 24,
[FromQuery] string? after = null,
CancellationToken ct = default)
{
var result = await _games.ListAsync(query, category, tag, mode, lifecycle, sort, limit, after, ct);
if (!result.IsSuccess) return MapError(result.Error!);

var (page, etag) = result.Value!;
return IfNoneMatchMatches(etag) ? NotModified(etag) : CachedOk(page, etag);
}

[HttpGet("featured")]
[EnableRateLimiting("catalog-read")]
[SwaggerOperation(Summary = "Get the single featured game, or none",
OperationId = "Games_GetFeatured", Tags = new[] { "Games" })]
[ProducesResponseType(typeof(GameCatalogDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status304NotModified)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)]
public async Task<IActionResult> GetFeatured(CancellationToken ct)
{
var result = await _games.GetFeaturedAsync(ct);
var featured = result.Value!;
if (featured.Game is null) return NoContent();

return IfNoneMatchMatches(featured.ETag!) ? NotModified(featured.ETag!) : CachedOk(featured.Game, featured.ETag!);
}

[HttpGet("{slug}")]
[EnableRateLimiting("catalog-read")]
[SwaggerOperation(Summary = "Get a single game by slug",
OperationId = "Games_GetBySlug", Tags = new[] { "Games" })]
[ProducesResponseType(typeof(GameCatalogDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status304NotModified)]
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(GameTombstoneDto), StatusCodes.Status410Gone)]
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)]
public async Task<IActionResult> GetBySlug([FromRoute] string slug, CancellationToken ct)
{
var result = await _games.GetDetailAsync(slug, ct);
if (!result.IsSuccess) return MapError(result.Error!);

var detail = result.Value!;
// 410 carries the minimal tombstone DTO directly (not the ApiErrorResponse envelope) per the spec's
// DTOs section; cache headers apply only to 200/304, never to 410.
if (detail.Tombstone is not null) return StatusCode(StatusCodes.Status410Gone, detail.Tombstone);

return IfNoneMatchMatches(detail.ETag!) ? NotModified(detail.ETag!) : CachedOk(detail.Game, detail.ETag!);
}

// ── Authenticated favorites ─────────────────────────────────────────────

[HttpGet("me/favorites")]
[Authorize]
[EnableRateLimiting("game-favorites")]
[SwaggerOperation(Summary = "List the authenticated user's active favorites (keyset cursor paged)",
OperationId = "Games_GetFavorites", Tags = new[] { "Games" })]
[ProducesResponseType(typeof(CursorPage<GameFavoriteDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)]
public async Task<IActionResult> GetFavorites(
[FromQuery] int limit = 24, [FromQuery] string? after = null, CancellationToken ct = default)
{
if (!TryGetUserId(out var userId)) return Unauthorized();

Response.Headers.CacheControl = "private, no-store";
var result = await _games.GetFavoritesAsync(userId, limit, after, ct);
return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!);
}

[HttpPut("me/favorites/{slug}")]
[Authorize]
[EnableRateLimiting("game-favorites")]
[SwaggerOperation(Summary = "Favorite a game (idempotent — identical DTO on repeat calls)",
OperationId = "Games_PutFavorite", Tags = new[] { "Games" })]
[ProducesResponseType(typeof(GameFavoriteDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status409Conflict)]
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)]
public async Task<IActionResult> PutFavorite([FromRoute] string slug, CancellationToken ct)
{
if (!HasCsrfHeader()) return MissingCsrfHeader();
if (!TryGetUserId(out var userId)) return Unauthorized();

Response.Headers.CacheControl = "private, no-store";
var result = await _games.PutFavoriteAsync(userId, slug, ct);
return result.IsSuccess ? Ok(result.Value) : MapError(result.Error!);
}

[HttpDelete("me/favorites/{slug}")]
[Authorize]
[EnableRateLimiting("game-favorites")]
[SwaggerOperation(Summary = "Unfavorite a game (idempotent — 204 whether present, absent, or already inactive)",
OperationId = "Games_DeleteFavorite", Tags = new[] { "Games" })]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)]
public async Task<IActionResult> DeleteFavorite([FromRoute] string slug, CancellationToken ct)
{
if (!HasCsrfHeader()) return MissingCsrfHeader();
if (!TryGetUserId(out var userId)) return Unauthorized();

Response.Headers.CacheControl = "private, no-store";
var result = await _games.DeleteFavoriteAsync(userId, slug, ct);
return result.IsSuccess ? NoContent() : MapError(result.Error!);
}

// ── Helpers ───────────────────────────────────────────────────────────────

private const string CsrfHeader = "X-Requested-With";
private const string CsrfHeaderValue = "XMLHttpRequest";

private bool HasCsrfHeader() =>
string.Equals(Request.Headers[CsrfHeader], CsrfHeaderValue, StringComparison.Ordinal);

private IActionResult MissingCsrfHeader() => BadRequest(Error(
"Auth.CsrfHeaderRequired",
$"The {CsrfHeader} header is required for this request."));

private bool TryGetUserId(out Guid userId) =>
Guid.TryParse(User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value, out userId);

private bool IfNoneMatchMatches(string etag)
{
var header = Request.Headers.IfNoneMatch.ToString();
if (string.IsNullOrEmpty(header)) return false;
return header.Split(',').Select(v => v.Trim()).Any(v => v == etag || v == "*");
}

private IActionResult NotModified(string etag)
{
Response.Headers.ETag = etag;
Response.Headers.CacheControl = "public, max-age=60";
Response.Headers.Vary = "Cookie";
return StatusCode(StatusCodes.Status304NotModified);
}

private IActionResult CachedOk<T>(T value, string etag)
{
Response.Headers.ETag = etag;
Response.Headers.CacheControl = "public, max-age=60";
Response.Headers.Vary = "Cookie";
return Ok(value);
}

/// <summary>Games.NotFound -> 404, Games.Retired -> 409 (new-favorite rejection only; the detail-read
/// 410 tombstone path never reaches this switch), everything else (Validation.Failed,
/// Pagination.InvalidCursor) -> 400.</summary>
private IActionResult MapError(Error error) =>
error.Code switch
{
"Games.NotFound" => NotFound(Error(error.Code, error.Message)),
"Games.Retired" => Conflict(Error(error.Code, error.Message)),
_ => BadRequest(Error(error.Code, error.Message)),
};

private static ApiErrorResponse Error(string code, string message) =>
new(new ApiErrorDetail(code, message));
}
57 changes: 46 additions & 11 deletions src/SimPle.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
using SimPle.Application.Common.Options;
using SimPle.Infrastructure;
using SimPle.Infrastructure.Auth;
using SimPle.Infrastructure.Games;
using SimPle.Infrastructure.Persistence;

var builder = WebApplication.CreateBuilder(args);

Expand Down Expand Up @@ -271,22 +273,44 @@ await context.HttpContext.Response.WriteAsJsonAsync(new ApiErrorResponse(
options.AddPolicy("profile-friends", context => FriendWindow(context, "pfrd", 120, TimeSpan.FromMinutes(1)));
options.AddPolicy("profile-mutual-friends", context => FriendWindow(context, "pmut", 120, TimeSpan.FromMinutes(1)));

// Chained per-IP ceiling for discovery/people-search (spec: 30/min/account + 120/hour/IP). Scoped by
// request path so it only "bites" on these two routes; every other route gets a permanent no-op lease.
// Game catalog policies (per IP for anonymous public reads; per account, IP-fallback for favorites).
options.AddPolicy("catalog-read", context => AuthWindow(context, 120, TimeSpan.FromMinutes(1)));
options.AddPolicy("game-favorites", context => FriendWindow(context, "gfav", 60, TimeSpan.FromMinutes(1)));

// Chained per-IP ceiling for discovery/people-search (spec: 30/min/account + 120/hour/IP), and for
// catalog search (spec: 120/min/IP catalog-read + 30/min/IP catalog-search when `query` is present).
// Scoped by request path/query so it only "bites" on these routes; every other route gets a permanent
// no-op lease.
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
var path = context.Request.Path;
if (!path.StartsWithSegments("/api/people/search") && !path.StartsWithSegments("/api/friends/discovery"))
return RateLimitPartition.GetNoLimiter("no-limit");

var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter($"people-ip:{ip}", _ => new FixedWindowRateLimiterOptions

if (path.StartsWithSegments("/api/people/search") || path.StartsWithSegments("/api/friends/discovery"))
{
PermitLimit = 120,
Window = TimeSpan.FromHours(1),
QueueLimit = 0,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
});
return RateLimitPartition.GetFixedWindowLimiter($"people-ip:{ip}", _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 120,
Window = TimeSpan.FromHours(1),
QueueLimit = 0,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
});
}

if (HttpMethods.IsGet(context.Request.Method) &&
path.StartsWithSegments("/api/games", out var remaining) && remaining == PathString.Empty &&
!string.IsNullOrEmpty(context.Request.Query["query"]))
{
return RateLimitPartition.GetFixedWindowLimiter($"catalog-search-ip:{ip}", _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 30,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst
});
}

return RateLimitPartition.GetNoLimiter("no-limit");
});
});

Expand Down Expand Up @@ -348,6 +372,17 @@ static RateLimitPartition<string> FriendWindow(HttpContext context, string prefi
});
}

if (args.Contains("--seed-game-catalog"))
{
using var scope = app.Services.CreateScope();
var seedDb = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var seedLogger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>().CreateLogger<GameCatalogSeeder>();
var seeder = new GameCatalogSeeder(seedDb, seedLogger);
var seedResult = seeder.SeedAsync().GetAwaiter().GetResult();
Console.WriteLine(seedResult.Message);
Environment.Exit(seedResult.Success ? 0 : 1);
}

app.Run();

public partial class Program { }
61 changes: 61 additions & 0 deletions src/SimPle.Application/Common/Interfaces/IGameRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using SimPle.Application.Games;
using SimPle.Domain.Games;
using SimPle.Domain.Outbox;

namespace SimPle.Application.Common.Interfaces;

/// <summary>
/// Fully normalized/validated catalog query, built by <see cref="SimPle.Application.Games.Services.GamesService"/>
/// and consumed by <c>GameRepository</c>. <see cref="AfterSortKey"/>/<see cref="AfterSlug"/> are the decoded
/// keyset position (null on the first page); their string shape is defined by <see cref="GameCatalogSortKey"/>.
/// </summary>
public sealed record GameCatalogFilter(
string? NormalizedSearch,
IReadOnlyList<string> Categories,
IReadOnlyList<string> Tags,
IReadOnlyList<string> Modes,
IReadOnlyList<GameLifecycle> Lifecycles,
string Sort,
int Limit,
string? AfterSortKey,
string? AfterSlug);

/// <summary>
/// Data access for the game catalog and favorites. List/detail/featured reads are budgeted at exactly two SQL
/// round trips: <see cref="GetCatalogPageAsync"/>/<see cref="GetBySlugAsync"/>/<see cref="GetFeaturedAsync"/>
/// fetch the game row(s) only, and <see cref="GetTagsAndCapabilitiesAsync"/> fetches tags and mode
/// capabilities for those ids in a single hand-written UNION ALL query (EF's <c>AsSplitQuery</c> would cost a
/// third round trip, which the spec's performance budget forbids).
/// </summary>
public interface IGameRepository
{
Task<IReadOnlyList<Game>> GetCatalogPageAsync(GameCatalogFilter filter, CancellationToken ct = default);

/// <summary>Flat (GameId, Kind, Value) rows for the given ids, Kind is "tag" or "mode".</summary>
Task<IReadOnlyList<(Guid GameId, string Kind, string Value)>> GetTagsAndCapabilitiesAsync(
IReadOnlyList<Guid> gameIds, CancellationToken ct = default);

/// <summary>Any lifecycle, including Draft/Retired — the service decides visibility (404 vs 410).</summary>
Task<Game?> GetBySlugAsync(string slug, CancellationToken ct = default);

/// <summary>The single FeaturedRank = 1 game, or null when none exists (204 case).</summary>
Task<Game?> GetFeaturedAsync(CancellationToken ct = default);

// ── Favorites ────────────────────────────────────────────────────────────

/// <summary>Active favorites ordered by (UpdatedAt DESC, Id DESC).</summary>
Task<IReadOnlyList<(UserFavoriteGame Favorite, Game Game)>> GetFavoritesPageAsync(
Guid userId, int limit, DateTime? afterFavoritedAt, Guid? afterId, CancellationToken ct = default);

Task<UserFavoriteGame?> GetFavoriteAsync(Guid userId, Guid gameId, CancellationToken ct = default);

/// <summary>Brand-new (UserId, GameId) row. Conflict = a concurrent first favorite already won the race.</summary>
Task<AddFavoriteOutcome> AddFavoriteAsync(UserFavoriteGame favorite, OutboxMessage evt, CancellationToken ct = default);

/// <summary>
/// Toggles an existing row (Refavorite/Unfavorite already called by the caller). ConcurrencyConflict means
/// the outbox's unique (AggregateId, EventType, AggregateDomainVersion) index rejected a retried transition
/// or a racing writer already applied it — the caller re-reads and converges.
/// </summary>
Task<UpdateFavoriteOutcome> UpdateFavoriteAsync(UserFavoriteGame favorite, OutboxMessage evt, CancellationToken ct = default);
}
23 changes: 23 additions & 0 deletions src/SimPle.Application/Common/Pagination/Cursor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,29 @@ public static bool TryDecodeProfileList(
return true;
}

// ── Game catalog cursor: (sortKey, Slug) position, bound to a hash of the normalized query shape
// (search text, category/tag/mode/lifecycle filters, sort) so a cursor cannot be replayed after the
// filter/sort shape changes — it must fail with Pagination.InvalidCursor rather than blend result sets.
// sortKey is a caller-formatted, order-preserving string representation of the active sort's key(s)
// (e.g. the default order's zero-padded FeaturedRank/SortOrder composite, or the uppercased Name).

public static string EncodeCatalog(string sortKey, string slug, string queryShapeHash) =>
ToBase64Url($"{ToBase64Url(sortKey)}{Sep}{ToBase64Url(slug)}{Sep}{ToBase64Url(queryShapeHash)}");

public static bool TryDecodeCatalog(string? cursor, out string sortKey, out string slug, out string queryShapeHash)
{
sortKey = string.Empty;
slug = string.Empty;
queryShapeHash = string.Empty;
if (!TryFromBase64Url(cursor, out var raw)) return false;
var parts = raw.Split(Sep);
if (parts.Length != 3) return false;
if (!TryFromBase64Url(parts[0], out sortKey)) return false;
if (!TryFromBase64Url(parts[1], out slug)) return false;
if (!TryFromBase64Url(parts[2], out queryShapeHash)) return false;
return true;
}

// ── base64url (RFC 4648 §5) without external dependencies ──

private static string ToBase64Url(string value)
Expand Down
Loading
Loading