Stop throwing for not-found; add Guard and request validation

Not-found lookups return null/false instead of throwing NotFoundException
across all services — a missing row is expected control flow, not an
exceptional condition. NotFoundException stays for embedded precondition
checks inside mutations (missing parent, invalid foreign reference).

Guard (copied from mic-check) enforces required arguments at the top of
every service method. A ported IModelValidator<T> framework validates
every request DTO at the API layer via a new ValidationEndpointFilter,
returning a 400 with field-level messages; services re-run the same
validator and throw for direct callers that bypass the API.

Endpoints translate null/false into 404 via a new ToApiResult() helper.
The agent toolset boundary translates the same nullable/bool results
into the tool-error text the model already expected.
This commit is contained in:
James Wampler
2026-08-06 15:13:36 -07:00
parent 04917fa09e
commit 40f93e40a8
45 changed files with 1523 additions and 377 deletions
+64 -17
View File
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Novelly.Api.Chapters;
using Novelly.Api.Characters;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
using Novelly.Api.Tags;
@@ -11,10 +12,18 @@ namespace Novelly.Api.Beats;
/// Beats are a chapter's outline: a flat, ordered table rather than a tree. Everything
/// here is scoped to one chapter.
/// </summary>
public class BeatService(INovelDbContext db, TagService tags, ILogger<BeatService> logger)
public class BeatService(
INovelDbContext db,
TagService tags,
ILogger<BeatService> logger,
IModelValidator<CreateBeatRequest> createValidator,
IModelValidator<UpdateBeatRequest> updateValidator,
IModelValidator<ReorderBeatsRequest> reorderValidator)
{
public async Task<IReadOnlyList<BeatDto>> ListAsync(Guid chapterId, CancellationToken ct = default)
{
Guard.Default(chapterId, nameof(chapterId));
logger.LogInformation("Listing beats for chapter {ChapterId}", chapterId);
var beats = await Query()
@@ -25,26 +34,32 @@ public class BeatService(INovelDbContext db, TagService tags, ILogger<BeatServic
return [.. beats.Select(b => b.ToDto())];
}
public async Task<BeatDto> GetAsync(Guid id, CancellationToken ct = default)
/// <summary>Null when no beat has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<BeatDto?> GetAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Getting beat {BeatId}", id);
return (await FindAsync(id, ct)).ToDto();
return (await FindAsync(id, ct))?.ToDto();
}
/// <summary>
/// Every beat this character appears in, in manuscript order. This is the character
/// page's view onto the outlines: each row carries its chapter so the UI can link
/// straight to the beat in that chapter's outline.
/// straight to the beat in that chapter's outline. Null when no character has this id;
/// an empty list means the character exists but has no beats yet.
/// </summary>
public async Task<IReadOnlyList<CharacterBeatDto>> ListForCharacterAsync(
public async Task<IReadOnlyList<CharacterBeatDto>?> ListForCharacterAsync(
Guid characterId, CancellationToken ct = default)
{
Guard.Default(characterId, nameof(characterId));
logger.LogInformation("Listing beats for character {CharacterId}", characterId);
if (!await db.Characters.AnyAsync(c => c.Id == characterId, ct))
{
logger.LogWarning("Character {CharacterId} not found", characterId);
throw new NotFoundException(nameof(Character), characterId);
logger.LogInformation("Character {CharacterId} not found", characterId);
return null;
}
var beats = await db.Beats
@@ -74,12 +89,16 @@ public class BeatService(INovelDbContext db, TagService tags, ILogger<BeatServic
public async Task<BeatDto> CreateAsync(Guid chapterId, CreateBeatRequest request, CancellationToken ct = default)
{
Guard.Default(chapterId, nameof(chapterId));
Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid();
logger.LogInformation("Creating beat {Title} for chapter {ChapterId}", request.Title, chapterId);
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct);
if (chapter is null)
{
logger.LogWarning("Chapter {ChapterId} not found", chapterId);
logger.LogWarning("Rejected beat creation: chapter {ChapterId} not found", chapterId);
throw new NotFoundException(nameof(Chapter), chapterId);
}
@@ -103,18 +122,31 @@ public class BeatService(INovelDbContext db, TagService tags, ILogger<BeatServic
db.Beats.Add(beat);
await db.SaveChangesAsync(ct);
return (await FindAsync(beat.Id, ct)).ToDto();
// Just created it — the reload is only to pick up includes, not to check existence.
return (await FindAsync(beat.Id, ct))!.ToDto();
}
public async Task<BeatDto> UpdateAsync(Guid id, UpdateBeatRequest request, CancellationToken ct = default)
public async Task<BeatDto?> UpdateAsync(Guid id, UpdateBeatRequest request, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request));
updateValidator.Validate(request).ThrowIfInvalid();
logger.LogInformation("Updating beat {BeatId}", id);
var beat = await FindAsync(id, ct);
if (beat is null)
{
return null;
}
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct);
if (chapter is null)
{
logger.LogWarning("Chapter {ChapterId} not found", beat.ChapterId);
// The beat's own chapter should always exist via the FK — this is an
// invariant failing, not a caller mistake, so it stays exceptional.
logger.LogError("Beat {BeatId} references chapter {ChapterId} which does not exist", id, beat.ChapterId);
throw new NotFoundException(nameof(Chapter), beat.ChapterId);
}
@@ -134,16 +166,25 @@ public class BeatService(INovelDbContext db, TagService tags, ILogger<BeatServic
}
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct)).ToDto();
return (await FindAsync(id, ct))!.ToDto();
}
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
/// <summary>True if a beat was deleted; false if no beat had this id.</summary>
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Deleting beat {BeatId}", id);
var beat = await FindAsync(id, ct);
if (beat is null)
{
return false;
}
db.Beats.Remove(beat);
await db.SaveChangesAsync(ct);
return true;
}
/// <summary>
@@ -153,6 +194,10 @@ public class BeatService(INovelDbContext db, TagService tags, ILogger<BeatServic
public async Task<IReadOnlyList<BeatDto>> ReorderAsync(
Guid chapterId, ReorderBeatsRequest request, CancellationToken ct = default)
{
Guard.Default(chapterId, nameof(chapterId));
Guard.Null(request, nameof(request));
reorderValidator.Validate(request).ThrowIfInvalid();
logger.LogInformation("Reordering {Count} beats for chapter {ChapterId}", request.BeatIds.Count, chapterId);
var beats = await db.Beats.Where(b => b.ChapterId == chapterId).ToListAsync(ct);
@@ -229,18 +274,20 @@ public class BeatService(INovelDbContext db, TagService tags, ILogger<BeatServic
.Include(b => b.Scene)
.Include(b => b.Tags);
private async Task<Beat> FindAsync(Guid id, CancellationToken ct)
private async Task<Beat?> FindAsync(Guid id, CancellationToken ct)
{
logger.LogDebug("Finding beat {BeatId}", id);
var beat = await Query().FirstOrDefaultAsync(b => b.Id == id, ct);
if (beat is null)
{
logger.LogWarning("Beat {BeatId} not found", id);
throw new NotFoundException(nameof(Beat), id);
logger.LogInformation("Beat {BeatId} not found", id);
}
else
{
logger.LogDebug("Found beat {BeatId}", id);
}
logger.LogDebug("Found beat {BeatId}", id);
return beat;
}
}