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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user