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:
@@ -1,5 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Data;
|
||||
|
||||
namespace Novelly.Api.Characters;
|
||||
@@ -13,10 +14,17 @@ namespace Novelly.Api.Characters;
|
||||
/// on a supporting character. Demoting someone should not delete work, and a character
|
||||
/// who turns out to matter gets promoted after the arc is already sketched.
|
||||
/// </remarks>
|
||||
public class CharacterArcService(INovelDbContext db, ILogger<CharacterArcService> logger)
|
||||
public class CharacterArcService(
|
||||
INovelDbContext db,
|
||||
ILogger<CharacterArcService> logger,
|
||||
IModelValidator<CreateArcStageRequest> createValidator,
|
||||
IModelValidator<UpdateArcStageRequest> updateValidator,
|
||||
IModelValidator<ReorderArcStagesRequest> reorderValidator)
|
||||
{
|
||||
public async Task<IReadOnlyList<ArcStageDto>> ListAsync(Guid characterId, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(characterId, nameof(characterId));
|
||||
|
||||
logger.LogInformation("Listing arc stages for character {CharacterId}", characterId);
|
||||
|
||||
var stages = await Query()
|
||||
@@ -27,21 +35,28 @@ public class CharacterArcService(INovelDbContext db, ILogger<CharacterArcService
|
||||
return [.. stages.Select(s => s.ToDto())];
|
||||
}
|
||||
|
||||
public async Task<ArcStageDto> GetAsync(Guid id, CancellationToken ct = default)
|
||||
/// <summary>Null when no arc stage has this id — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<ArcStageDto?> GetAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
|
||||
logger.LogInformation("Getting arc stage {ArcStageId}", id);
|
||||
return (await FindAsync(id, ct)).ToDto();
|
||||
return (await FindAsync(id, ct))?.ToDto();
|
||||
}
|
||||
|
||||
public async Task<ArcStageDto> CreateAsync(
|
||||
Guid characterId, CreateArcStageRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(characterId, nameof(characterId));
|
||||
Guard.Null(request, nameof(request));
|
||||
createValidator.Validate(request).ThrowIfInvalid();
|
||||
|
||||
logger.LogInformation("Creating arc stage {Title} for character {CharacterId}", request.Title, characterId);
|
||||
|
||||
var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == characterId, ct);
|
||||
if (character is null)
|
||||
{
|
||||
logger.LogWarning("Character {CharacterId} not found", characterId);
|
||||
logger.LogWarning("Rejected arc stage creation: character {CharacterId} not found", characterId);
|
||||
throw new NotFoundException(nameof(Character), characterId);
|
||||
}
|
||||
|
||||
@@ -58,20 +73,32 @@ public class CharacterArcService(INovelDbContext db, ILogger<CharacterArcService
|
||||
|
||||
db.CharacterArcStages.Add(stage);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(stage.Id, ct)).ToDto();
|
||||
|
||||
// Just created it — the reload is only to pick up includes, not to check existence.
|
||||
return (await FindAsync(stage.Id, ct))!.ToDto();
|
||||
}
|
||||
|
||||
public async Task<ArcStageDto> UpdateAsync(
|
||||
public async Task<ArcStageDto?> UpdateAsync(
|
||||
Guid id, UpdateArcStageRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
Guard.Null(request, nameof(request));
|
||||
updateValidator.Validate(request).ThrowIfInvalid();
|
||||
|
||||
logger.LogInformation("Updating arc stage {ArcStageId}", id);
|
||||
|
||||
var stage = await FindAsync(id, ct);
|
||||
if (stage is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == stage.CharacterId, ct);
|
||||
if (character is null)
|
||||
{
|
||||
logger.LogWarning("Character {CharacterId} not found", stage.CharacterId);
|
||||
// The stage's own character should always exist via the FK — this is an
|
||||
// invariant failing, not a caller mistake, so it stays exceptional.
|
||||
logger.LogError("Arc stage {ArcStageId} references character {CharacterId} which does not exist", id, stage.CharacterId);
|
||||
throw new NotFoundException(nameof(Character), stage.CharacterId);
|
||||
}
|
||||
|
||||
@@ -84,16 +111,25 @@ public class CharacterArcService(INovelDbContext db, ILogger<CharacterArcService
|
||||
stage.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
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 an arc stage was deleted; false if no stage had this id.</summary>
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
|
||||
logger.LogInformation("Deleting arc stage {ArcStageId}", id);
|
||||
|
||||
var stage = await FindAsync(id, ct);
|
||||
if (stage is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
db.CharacterArcStages.Remove(stage);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -103,6 +139,10 @@ public class CharacterArcService(INovelDbContext db, ILogger<CharacterArcService
|
||||
public async Task<IReadOnlyList<ArcStageDto>> ReorderAsync(
|
||||
Guid characterId, ReorderArcStagesRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(characterId, nameof(characterId));
|
||||
Guard.Null(request, nameof(request));
|
||||
reorderValidator.Validate(request).ThrowIfInvalid();
|
||||
|
||||
logger.LogInformation("Reordering {Count} arc stages for character {CharacterId}", request.StageIds.Count, characterId);
|
||||
|
||||
var stages = await db.CharacterArcStages
|
||||
@@ -164,18 +204,20 @@ public class CharacterArcService(INovelDbContext db, ILogger<CharacterArcService
|
||||
|
||||
private IQueryable<CharacterArcStage> Query() => db.CharacterArcStages.Include(s => s.Chapter);
|
||||
|
||||
private async Task<CharacterArcStage> FindAsync(Guid id, CancellationToken ct)
|
||||
private async Task<CharacterArcStage?> FindAsync(Guid id, CancellationToken ct)
|
||||
{
|
||||
logger.LogDebug("Finding arc stage {ArcStageId}", id);
|
||||
|
||||
var stage = await Query().FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||
if (stage is null)
|
||||
{
|
||||
logger.LogWarning("CharacterArcStage {ArcStageId} not found", id);
|
||||
throw new NotFoundException(nameof(CharacterArcStage), id);
|
||||
logger.LogInformation("CharacterArcStage {ArcStageId} not found", id);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogDebug("Found arc stage {ArcStageId}", id);
|
||||
}
|
||||
|
||||
logger.LogDebug("Found arc stage {ArcStageId}", id);
|
||||
return stage;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user