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
+14 -14
View File
@@ -5,16 +5,16 @@ namespace Novelly.Api.Agent;
/// <summary>A chat thread between the writer and the embedded agent, scoped to one project.</summary> /// <summary>A chat thread between the writer and the embedded agent, scoped to one project.</summary>
public class AgentConversation public class AgentConversation
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; init; } = Guid.NewGuid();
public Guid ProjectId { get; set; } public Guid ProjectId { get; init; }
public Project? Project { get; set; } public Project? Project { get; init; }
public string Title { get; set; } = "New conversation"; public string Title { get; init; } = "New conversation";
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
public List<AgentMessage> Messages { get; set; } = []; public List<AgentMessage> Messages { get; init; } = [];
} }
/// <summary> /// <summary>
@@ -24,26 +24,26 @@ public class AgentConversation
/// </summary> /// </summary>
public class AgentMessage public class AgentMessage
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; init; } = Guid.NewGuid();
public Guid ConversationId { get; set; } public Guid ConversationId { get; init; }
public AgentConversation? Conversation { get; set; } public AgentConversation? Conversation { get; init; }
public AgentRole Role { get; set; } public AgentRole Role { get; init; }
/// <summary> /// <summary>
/// Position in the conversation, 0-based. Timestamps are not enough to order a /// Position in the conversation, 0-based. Timestamps are not enough to order a
/// transcript: a fast turn can produce two messages inside the same tick. /// transcript: a fast turn can produce two messages inside the same tick.
/// </summary> /// </summary>
public int Sequence { get; set; } public int Sequence { get; init; }
/// <summary>The visible text of the turn.</summary> /// <summary>The visible text of the turn.</summary>
public string Content { get; set; } = string.Empty; public string Content { get; init; } = string.Empty;
/// <summary> /// <summary>
/// JSON array of <c>{ name, input, result }</c> objects describing tool calls made /// JSON array of <c>{ name, input, result }</c> objects describing tool calls made
/// during this turn. Null on user turns and on assistant turns that used no tools. /// during this turn. Null on user turns and on assistant turns that used no tools.
/// </summary> /// </summary>
public string? ToolCallsJson { get; set; } public string? ToolCallsJson { get; init; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
} }
+17
View File
@@ -1,3 +1,5 @@
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Agent; namespace Novelly.Api.Agent;
public record ConversationSummaryDto( public record ConversationSummaryDto(
@@ -26,4 +28,19 @@ public record ToolCallDto(string Name, string Input, string Result);
public record SendAgentMessageRequest(string Message, Guid? ConversationId = null); public record SendAgentMessageRequest(string Message, Guid? ConversationId = null);
public class SendAgentMessageRequestValidator : IModelValidator<SendAgentMessageRequest>
{
public ValidationResult Validate(SendAgentMessageRequest model)
{
var result = new ValidationResult();
if (string.IsNullOrWhiteSpace(model.Message))
result.AddError("Message", "'Message' must not be empty.");
else if (model.Message.Length > 20000)
result.AddError("Message", "'Message' must be 20,000 characters or fewer.");
return result;
}
}
public record AgentTurnDto(Guid ConversationId, AgentMessageDto Message); public record AgentTurnDto(Guid ConversationId, AgentMessageDto Message);
+9 -7
View File
@@ -1,4 +1,5 @@
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Agent; namespace Novelly.Api.Agent;
@@ -6,7 +7,9 @@ public static class AgentEndpoints
{ {
public static IEndpointRouteBuilder MapAgentEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapAgentEndpoints(this IEndpointRouteBuilder app)
{ {
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/agent").WithTags("Agent").AddEndpointFilter<RequestLoggingEndpointFilter>(); var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/agent").WithTags("Agent")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
projectScoped.MapGet("/conversations", async ( projectScoped.MapGet("/conversations", async (
Guid projectId, NovelAgentService agent, CancellationToken ct) => Guid projectId, NovelAgentService agent, CancellationToken ct) =>
@@ -21,17 +24,16 @@ public static class AgentEndpoints
Results.Ok(await agent.SendMessageAsync(projectId, request, ct))) Results.Ok(await agent.SendMessageAsync(projectId, request, ct)))
.WithSummary("Send a message to the writing agent and run it to completion."); .WithSummary("Send a message to the writing agent and run it to completion.");
var conversations = app.MapGroup("/api/conversations").WithTags("Agent").AddEndpointFilter<RequestLoggingEndpointFilter>(); var conversations = app.MapGroup("/api/conversations").WithTags("Agent")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
conversations.MapGet("/{id:guid}", async (Guid id, NovelAgentService agent, CancellationToken ct) => conversations.MapGet("/{id:guid}", async (Guid id, NovelAgentService agent, CancellationToken ct) =>
Results.Ok(await agent.GetConversationAsync(id, ct))) (await agent.GetConversationAsync(id, ct)).ToApiResult())
.WithSummary("Read a conversation's full transcript."); .WithSummary("Read a conversation's full transcript.");
conversations.MapDelete("/{id:guid}", async (Guid id, NovelAgentService agent, CancellationToken ct) => conversations.MapDelete("/{id:guid}", async (Guid id, NovelAgentService agent, CancellationToken ct) =>
{ await agent.DeleteConversationAsync(id, ct) ? Results.NoContent() : Results.NotFound())
await agent.DeleteConversationAsync(id, ct);
return Results.NoContent();
})
.WithSummary("Delete a conversation."); .WithSummary("Delete a conversation.");
return app; return app;
+34 -10
View File
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data; using Novelly.Api.Data;
using Novelly.Api.Projects; using Novelly.Api.Projects;
@@ -18,7 +19,8 @@ public class NovelAgentService(
IAgentModelClient model, IAgentModelClient model,
NovelAgentToolset toolset, NovelAgentToolset toolset,
IOptions<AgentOptions> options, IOptions<AgentOptions> options,
ILogger<NovelAgentService> logger) ILogger<NovelAgentService> logger,
IModelValidator<SendAgentMessageRequest> sendMessageValidator)
{ {
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
{ {
@@ -39,11 +41,18 @@ public class NovelAgentService(
.ToListAsync(ct); .ToListAsync(ct);
} }
public async Task<ConversationDto> GetConversationAsync(Guid conversationId, CancellationToken ct = default) /// <summary>Null when no conversation has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<ConversationDto?> GetConversationAsync(Guid conversationId, CancellationToken ct = default)
{ {
Guard.Default(conversationId, nameof(conversationId));
logger.LogInformation("Getting agent conversation {ConversationId}", conversationId); logger.LogInformation("Getting agent conversation {ConversationId}", conversationId);
var conversation = await LoadConversationAsync(conversationId, ct); var conversation = await FindConversationAsync(conversationId, ct);
if (conversation is null)
{
return null;
}
return new ConversationDto( return new ConversationDto(
conversation.Id, conversation.Id,
@@ -53,13 +62,22 @@ public class NovelAgentService(
conversation.UpdatedAt); conversation.UpdatedAt);
} }
public async Task DeleteConversationAsync(Guid conversationId, CancellationToken ct = default) /// <summary>True if a conversation was deleted; false if no conversation had this id.</summary>
public async Task<bool> DeleteConversationAsync(Guid conversationId, CancellationToken ct = default)
{ {
Guard.Default(conversationId, nameof(conversationId));
logger.LogInformation("Deleting agent conversation {ConversationId}", conversationId); logger.LogInformation("Deleting agent conversation {ConversationId}", conversationId);
var conversation = await LoadConversationAsync(conversationId, ct); var conversation = await FindConversationAsync(conversationId, ct);
if (conversation is null)
{
return false;
}
db.Conversations.Remove(conversation); db.Conversations.Remove(conversation);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return true;
} }
/// <summary> /// <summary>
@@ -69,12 +87,19 @@ public class NovelAgentService(
public async Task<AgentTurnDto> SendMessageAsync( public async Task<AgentTurnDto> SendMessageAsync(
Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default) Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId));
Guard.Null(request, nameof(request));
sendMessageValidator.Validate(request).ThrowIfInvalid();
logger.LogInformation( logger.LogInformation(
"Sending agent message for project {ProjectId}, conversation {ConversationId}, message length {MessageLength}", "Sending agent message for project {ProjectId}, conversation {ConversationId}, message length {MessageLength}",
projectId, request.ConversationId, request.Message.Length); projectId, request.ConversationId, request.Message.Length);
var conversation = request.ConversationId is { } id var conversation = request.ConversationId is { } id
? await LoadConversationAsync(id, ct) ? await FindConversationAsync(id, ct)
// The id came from the request body, not the route — an unknown id here
// is bad input to this call, not a direct "fetch conversation" lookup.
?? throw new NotFoundException(nameof(AgentConversation), id)
: await StartConversationAsync(projectId, request.Message, ct); : await StartConversationAsync(projectId, request.Message, ct);
// Persist the user's turn before running the loop. The tools save through the // Persist the user's turn before running the loop. The tools save through the
@@ -204,9 +229,9 @@ public class NovelAgentService(
return conversation; return conversation;
} }
private async Task<AgentConversation> LoadConversationAsync(Guid conversationId, CancellationToken ct) private async Task<AgentConversation?> FindConversationAsync(Guid conversationId, CancellationToken ct)
{ {
logger.LogDebug("Loading agent conversation {ConversationId}", conversationId); logger.LogDebug("Finding agent conversation {ConversationId}", conversationId);
var conversation = await db.Conversations var conversation = await db.Conversations
.Include(c => c.Messages) .Include(c => c.Messages)
@@ -214,8 +239,7 @@ public class NovelAgentService(
if (conversation is null) if (conversation is null)
{ {
logger.LogWarning("AgentConversation {ConversationId} not found", conversationId); logger.LogInformation("AgentConversation {ConversationId} not found", conversationId);
throw new NotFoundException(nameof(AgentConversation), conversationId);
} }
return conversation; return conversation;
+144 -85
View File
@@ -13,6 +13,13 @@ namespace Novelly.Api.Agent;
/// <summary>The outcome of running a tool: what to hand back to the model, and whether it failed.</summary> /// <summary>The outcome of running a tool: what to hand back to the model, and whether it failed.</summary>
public record AgentToolResult(string Content, bool IsError); public record AgentToolResult(string Content, bool IsError);
/// <summary>
/// A lookup a tool performed came back empty. Not an exception — the underlying service
/// already said so by returning null/false — just a value <see cref="NovelAgentToolset.ExecuteAsync"/>
/// recognises and turns into the same error-result shape a caught exception would produce.
/// </summary>
internal record ToolNotFound(string Message);
/// <summary>A tool the agent can call, bound to a handler that runs against the project's data.</summary> /// <summary>A tool the agent can call, bound to a handler that runs against the project's data.</summary>
public record AgentTool( public record AgentTool(
string Name, string Name,
@@ -44,7 +51,7 @@ public class NovelAgentToolset(
private Dictionary<string, AgentTool>? _byName; private Dictionary<string, AgentTool>? _byName;
public IReadOnlyList<AgentTool> Tools => [.. ByName.Values]; private IReadOnlyList<AgentTool> Tools => [.. ByName.Values];
public IReadOnlyList<AgentToolDefinition> Definitions => public IReadOnlyList<AgentToolDefinition> Definitions =>
[.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))]; [.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))];
@@ -53,8 +60,7 @@ public class NovelAgentToolset(
/// Runs a tool and serialises its result. Failures come back as text rather than /// Runs a tool and serialises its result. Failures come back as text rather than
/// exceptions so the model can read the message and correct itself. /// exceptions so the model can read the message and correct itself.
/// </summary> /// </summary>
public async Task<AgentToolResult> ExecuteAsync( public async Task<AgentToolResult> ExecuteAsync(string name, Guid projectId, JsonElement input, CancellationToken ct = default)
string name, Guid projectId, JsonElement input, CancellationToken ct = default)
{ {
if (!ByName.TryGetValue(name, out var tool)) if (!ByName.TryGetValue(name, out var tool))
{ {
@@ -67,6 +73,13 @@ public class NovelAgentToolset(
try try
{ {
var result = await tool.Handler(projectId, input, ct); var result = await tool.Handler(projectId, input, ct);
if (result is ToolNotFound notFound)
{
logger.LogInformation("Tool {Tool} for project {ProjectId} found nothing: {Message}", name, projectId, notFound.Message);
return new AgentToolResult(notFound.Message, true);
}
logger.LogDebug("Tool {Tool} for project {ProjectId} succeeded", name, projectId); logger.LogDebug("Tool {Tool} for project {ProjectId} succeeded", name, projectId);
return new AgentToolResult(JsonSerializer.Serialize(result, SerializerOptions), false); return new AgentToolResult(JsonSerializer.Serialize(result, SerializerOptions), false);
} }
@@ -87,6 +100,14 @@ public class NovelAgentToolset(
} }
} }
/// <summary>Turns a nullable lookup into either the value or a <see cref="ToolNotFound"/> the model can read.</summary>
private static async Task<object> OrNotFound<T>(Task<T?> lookup, string entity, Guid id) where T : class =>
await lookup as object ?? new ToolNotFound($"{entity} '{id}' was not found.");
/// <summary>Turns a delete's success flag into either a confirmation or a <see cref="ToolNotFound"/>.</summary>
private static async Task<object> DeletedOrNotFound(Task<bool> delete, string entity, Guid id) =>
await delete ? new { deleted = true } : new ToolNotFound($"{entity} '{id}' was not found.");
private Dictionary<string, AgentTool> ByName => _byName ??= Build().ToDictionary(t => t.Name); private Dictionary<string, AgentTool> ByName => _byName ??= Build().ToDictionary(t => t.Name);
private IEnumerable<AgentTool> Build() private IEnumerable<AgentTool> Build()
@@ -96,7 +117,7 @@ public class NovelAgentToolset(
"Read the project's title, logline, synopsis, genre, notes and word-count target. " "Read the project's title, logline, synopsis, genre, notes and word-count target. "
+ "Call this first in a conversation to ground yourself in what the book is.", + "Call this first in a conversation to ground yourself in what the book is.",
new JsonSchemaBuilder().Build(), new JsonSchemaBuilder().Build(),
async (projectId, _, ct) => await projects.GetAsync(projectId, ct)); async (projectId, _, ct) => await OrNotFound(projects.GetAsync(projectId, ct), "Project", projectId));
yield return new AgentTool( yield return new AgentTool(
"update_project_brief", "update_project_brief",
@@ -111,14 +132,14 @@ public class NovelAgentToolset(
.Str("notes", "Free-form notes on theme, tone, comparable titles.") .Str("notes", "Free-form notes on theme, tone, comparable titles.")
.Int("target_word_count", "Target manuscript length in words.") .Int("target_word_count", "Target manuscript length in words.")
.Build(), .Build(),
async (projectId, input, ct) => await projects.UpdateAsync(projectId, new UpdateProjectRequest( async (projectId, input, ct) => await OrNotFound(projects.UpdateAsync(projectId, new UpdateProjectRequest(
JsonInput.String(input, "title"), JsonInput.String(input, "title"),
JsonInput.String(input, "author"), JsonInput.String(input, "author"),
JsonInput.String(input, "genre"), JsonInput.String(input, "genre"),
JsonInput.String(input, "logline"), JsonInput.String(input, "logline"),
JsonInput.String(input, "synopsis"), JsonInput.String(input, "synopsis"),
JsonInput.String(input, "notes"), JsonInput.String(input, "notes"),
JsonInput.Int(input, "target_word_count")), ct)); JsonInput.Int(input, "target_word_count")), ct), "Project", projectId));
yield return new AgentTool( yield return new AgentTool(
"list_characters", "list_characters",
@@ -156,26 +177,30 @@ public class NovelAgentToolset(
CharacterSchema(includeName: true, nameRequired: false) CharacterSchema(includeName: true, nameRequired: false)
.Str("character_id", "Id of the character to update.", required: true) .Str("character_id", "Id of the character to update.", required: true)
.Build(), .Build(),
async (_, input, ct) => await characters.UpdateAsync( async (_, input, ct) =>
JsonInput.RequiredGuid(input, "character_id"), {
new UpdateCharacterRequest( var characterId = JsonInput.RequiredGuid(input, "character_id");
JsonInput.String(input, "name"), return await OrNotFound(characters.UpdateAsync(
JsonInput.Enum<CharacterRole>(input, "role"), characterId,
JsonInput.Enum<CharacterImportance>(input, "importance"), new UpdateCharacterRequest(
JsonInput.String(input, "age"), JsonInput.String(input, "name"),
JsonInput.String(input, "pronouns"), JsonInput.Enum<CharacterRole>(input, "role"),
JsonInput.String(input, "occupation"), JsonInput.Enum<CharacterImportance>(input, "importance"),
JsonInput.String(input, "appearance"), JsonInput.String(input, "age"),
JsonInput.String(input, "personality"), JsonInput.String(input, "pronouns"),
JsonInput.String(input, "backstory"), JsonInput.String(input, "occupation"),
JsonInput.String(input, "want"), JsonInput.String(input, "appearance"),
JsonInput.String(input, "need"), JsonInput.String(input, "personality"),
JsonInput.String(input, "internal_conflict"), JsonInput.String(input, "backstory"),
JsonInput.String(input, "external_conflict"), JsonInput.String(input, "want"),
JsonInput.String(input, "arc_summary"), JsonInput.String(input, "need"),
JsonInput.String(input, "voice"), JsonInput.String(input, "internal_conflict"),
JsonInput.String(input, "notes"), JsonInput.String(input, "external_conflict"),
JsonInput.Strings(input, "tags")), ct)); JsonInput.String(input, "arc_summary"),
JsonInput.String(input, "voice"),
JsonInput.String(input, "notes"),
JsonInput.Strings(input, "tags")), ct), "Character", characterId);
});
yield return new AgentTool( yield return new AgentTool(
"get_chapter_outline", "get_chapter_outline",
@@ -213,16 +238,20 @@ public class NovelAgentToolset(
.Str("beat_id", "Id of the beat to update.", required: true) .Str("beat_id", "Id of the beat to update.", required: true)
.Str("title", "Three to five words naming the beat.") .Str("title", "Three to five words naming the beat.")
.Build(), .Build(),
async (_, input, ct) => await beats.UpdateAsync( async (_, input, ct) =>
JsonInput.RequiredGuid(input, "beat_id"), {
new UpdateBeatRequest( var beatId = JsonInput.RequiredGuid(input, "beat_id");
JsonInput.String(input, "title"), return await OrNotFound(beats.UpdateAsync(
JsonInput.Int(input, "sort_order"), beatId,
JsonInput.Guid(input, "character_id"), new UpdateBeatRequest(
JsonInput.String(input, "what_happened"), JsonInput.String(input, "title"),
JsonInput.String(input, "whats_next"), JsonInput.Int(input, "sort_order"),
JsonInput.Guid(input, "scene_id"), JsonInput.Guid(input, "character_id"),
JsonInput.Strings(input, "tags")), ct)); JsonInput.String(input, "what_happened"),
JsonInput.String(input, "whats_next"),
JsonInput.Guid(input, "scene_id"),
JsonInput.Strings(input, "tags")), ct), "Beat", beatId);
});
yield return new AgentTool( yield return new AgentTool(
"delete_beat", "delete_beat",
@@ -232,8 +261,8 @@ public class NovelAgentToolset(
.Build(), .Build(),
async (_, input, ct) => async (_, input, ct) =>
{ {
await beats.DeleteAsync(JsonInput.RequiredGuid(input, "beat_id"), ct); var beatId = JsonInput.RequiredGuid(input, "beat_id");
return new { deleted = true }; return await DeletedOrNotFound(beats.DeleteAsync(beatId, ct), "Beat", beatId);
}); });
yield return new AgentTool( yield return new AgentTool(
@@ -265,7 +294,11 @@ public class NovelAgentToolset(
new JsonSchemaBuilder() new JsonSchemaBuilder()
.Str("tag_id", "Id of the tag to trace.", required: true) .Str("tag_id", "Id of the tag to trace.", required: true)
.Build(), .Build(),
async (_, input, ct) => await tags.GetReferencesAsync(JsonInput.RequiredGuid(input, "tag_id"), ct)); async (_, input, ct) =>
{
var tagId = JsonInput.RequiredGuid(input, "tag_id");
return await OrNotFound(tags.GetReferencesAsync(tagId, ct), "Tag", tagId);
});
yield return new AgentTool( yield return new AgentTool(
"list_chapters", "list_chapters",
@@ -279,7 +312,11 @@ public class NovelAgentToolset(
new JsonSchemaBuilder() new JsonSchemaBuilder()
.Str("chapter_id", "Id of the chapter to read.", required: true) .Str("chapter_id", "Id of the chapter to read.", required: true)
.Build(), .Build(),
async (_, input, ct) => await chapters.GetAsync(JsonInput.RequiredGuid(input, "chapter_id"), ct)); async (_, input, ct) =>
{
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
return await OrNotFound(chapters.GetAsync(chapterId, ct), "Chapter", chapterId);
});
yield return new AgentTool( yield return new AgentTool(
"create_chapter", "create_chapter",
@@ -321,18 +358,22 @@ public class NovelAgentToolset(
.Int("target_word_count", "Target length in words.") .Int("target_word_count", "Target length in words.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.") .StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
.Build(), .Build(),
async (_, input, ct) => await chapters.UpdateAsync( async (_, input, ct) =>
JsonInput.RequiredGuid(input, "chapter_id"), {
new UpdateChapterRequest( var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
JsonInput.String(input, "title"), return await OrNotFound(chapters.UpdateAsync(
JsonInput.Int(input, "number"), chapterId,
JsonInput.String(input, "summary"), new UpdateChapterRequest(
JsonInput.Guid(input, "pov_character_id"), JsonInput.String(input, "title"),
JsonInput.String(input, "setting"), JsonInput.Int(input, "number"),
JsonInput.String(input, "notes"), JsonInput.String(input, "summary"),
JsonInput.Enum<DraftStatus>(input, "status"), JsonInput.Guid(input, "pov_character_id"),
JsonInput.Int(input, "target_word_count"), JsonInput.String(input, "setting"),
JsonInput.Strings(input, "tags")), ct)); JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status"),
JsonInput.Int(input, "target_word_count"),
JsonInput.Strings(input, "tags")), ct), "Chapter", chapterId);
});
yield return new AgentTool( yield return new AgentTool(
"create_scene", "create_scene",
@@ -364,19 +405,23 @@ public class NovelAgentToolset(
.Str("scene_id", "Id of the scene to update.", required: true) .Str("scene_id", "Id of the scene to update.", required: true)
.Str("title", "New title.") .Str("title", "New title.")
.Build(), .Build(),
async (_, input, ct) => await scenes.UpdateAsync( async (_, input, ct) =>
JsonInput.RequiredGuid(input, "scene_id"), {
new UpdateSceneRequest( var sceneId = JsonInput.RequiredGuid(input, "scene_id");
JsonInput.String(input, "title"), return await OrNotFound(scenes.UpdateAsync(
JsonInput.Int(input, "sort_order"), sceneId,
JsonInput.String(input, "summary"), new UpdateSceneRequest(
JsonInput.String(input, "goal"), JsonInput.String(input, "title"),
JsonInput.String(input, "conflict"), JsonInput.Int(input, "sort_order"),
JsonInput.String(input, "outcome"), JsonInput.String(input, "summary"),
JsonInput.Guid(input, "pov_character_id"), JsonInput.String(input, "goal"),
JsonInput.String(input, "location"), JsonInput.String(input, "conflict"),
JsonInput.String(input, "prose"), JsonInput.String(input, "outcome"),
JsonInput.Enum<DraftStatus>(input, "status")), ct)); JsonInput.Guid(input, "pov_character_id"),
JsonInput.String(input, "location"),
JsonInput.String(input, "prose"),
JsonInput.Enum<DraftStatus>(input, "status")), ct), "Scene", sceneId);
});
yield return new AgentTool( yield return new AgentTool(
"get_character_beats", "get_character_beats",
@@ -386,8 +431,11 @@ public class NovelAgentToolset(
new JsonSchemaBuilder() new JsonSchemaBuilder()
.Str("character_id", "Id of the character.", required: true) .Str("character_id", "Id of the character.", required: true)
.Build(), .Build(),
async (_, input, ct) => await beats.ListForCharacterAsync( async (_, input, ct) =>
JsonInput.RequiredGuid(input, "character_id"), ct)); {
var characterId = JsonInput.RequiredGuid(input, "character_id");
return await OrNotFound(beats.ListForCharacterAsync(characterId, ct), "Character", characterId);
});
yield return new AgentTool( yield return new AgentTool(
"get_character_arc", "get_character_arc",
@@ -422,13 +470,17 @@ public class NovelAgentToolset(
.Str("arc_stage_id", "Id of the arc stage to update.", required: true) .Str("arc_stage_id", "Id of the arc stage to update.", required: true)
.Str("title", "New title for the stage.") .Str("title", "New title for the stage.")
.Build(), .Build(),
async (_, input, ct) => await arcs.UpdateAsync( async (_, input, ct) =>
JsonInput.RequiredGuid(input, "arc_stage_id"), {
new UpdateArcStageRequest( var arcStageId = JsonInput.RequiredGuid(input, "arc_stage_id");
JsonInput.String(input, "title"), return await OrNotFound(arcs.UpdateAsync(
JsonInput.Int(input, "sort_order"), arcStageId,
JsonInput.String(input, "description"), new UpdateArcStageRequest(
JsonInput.Guid(input, "chapter_id")), ct)); JsonInput.String(input, "title"),
JsonInput.Int(input, "sort_order"),
JsonInput.String(input, "description"),
JsonInput.Guid(input, "chapter_id")), ct), "CharacterArcStage", arcStageId);
});
yield return new AgentTool( yield return new AgentTool(
"delete_arc_stage", "delete_arc_stage",
@@ -438,8 +490,8 @@ public class NovelAgentToolset(
.Build(), .Build(),
async (_, input, ct) => async (_, input, ct) =>
{ {
await arcs.DeleteAsync(JsonInput.RequiredGuid(input, "arc_stage_id"), ct); var arcStageId = JsonInput.RequiredGuid(input, "arc_stage_id");
return new { deleted = true }; return await DeletedOrNotFound(arcs.DeleteAsync(arcStageId, ct), "CharacterArcStage", arcStageId);
}); });
yield return new AgentTool( yield return new AgentTool(
@@ -499,11 +551,15 @@ public class NovelAgentToolset(
.Str("resolution", "What was decided.", required: true) .Str("resolution", "What was decided.", required: true)
.Bool("append_to_notes", "Also append the resolution to the associated notes.") .Bool("append_to_notes", "Also append the resolution to the associated notes.")
.Build(), .Build(),
async (_, input, ct) => await questions.ResolveAsync( async (_, input, ct) =>
JsonInput.RequiredGuid(input, "question_id"), {
new ResolveOpenQuestionRequest( var questionId = JsonInput.RequiredGuid(input, "question_id");
JsonInput.RequiredString(input, "resolution"), return await OrNotFound(questions.ResolveAsync(
JsonInput.Bool(input, "append_to_notes") ?? false), ct)); questionId,
new ResolveOpenQuestionRequest(
JsonInput.RequiredString(input, "resolution"),
JsonInput.Bool(input, "append_to_notes") ?? false), ct), "OpenQuestion", questionId);
});
yield return new AgentTool( yield return new AgentTool(
"reopen_question", "reopen_question",
@@ -511,8 +567,11 @@ public class NovelAgentToolset(
new JsonSchemaBuilder() new JsonSchemaBuilder()
.Str("question_id", "Id of the question to reopen.", required: true) .Str("question_id", "Id of the question to reopen.", required: true)
.Build(), .Build(),
async (_, input, ct) => await questions.ReopenAsync( async (_, input, ct) =>
JsonInput.RequiredGuid(input, "question_id"), ct)); {
var questionId = JsonInput.RequiredGuid(input, "question_id");
return await OrNotFound(questions.ReopenAsync(questionId, ct), "OpenQuestion", questionId);
});
yield return new AgentTool( yield return new AgentTool(
"delete_open_question", "delete_open_question",
@@ -522,8 +581,8 @@ public class NovelAgentToolset(
.Build(), .Build(),
async (_, input, ct) => async (_, input, ct) =>
{ {
await questions.DeleteAsync(JsonInput.RequiredGuid(input, "question_id"), ct); var questionId = JsonInput.RequiredGuid(input, "question_id");
return new { deleted = true }; return await DeletedOrNotFound(questions.DeleteAsync(questionId, ct), "OpenQuestion", questionId);
}); });
} }
+69
View File
@@ -1,3 +1,4 @@
using Novelly.Api.Common.Validation;
using Novelly.Api.Tags; using Novelly.Api.Tags;
namespace Novelly.Api.Beats; namespace Novelly.Api.Beats;
@@ -25,6 +26,23 @@ public record CreateBeatRequest(
Guid? SceneId = null, Guid? SceneId = null,
IReadOnlyList<string>? Tags = null); IReadOnlyList<string>? Tags = null);
public class CreateBeatRequestValidator : IModelValidator<CreateBeatRequest>
{
public ValidationResult Validate(CreateBeatRequest model)
{
var result = new ValidationResult();
if (string.IsNullOrWhiteSpace(model.Title))
result.AddError("Title", "'Title' must not be empty.");
else if (model.Title.Length > 200)
result.AddError("Title", "'Title' must be 200 characters or fewer.");
BeatValidation.OptionalFields(model.SortOrder, model.WhatHappened, model.WhatsNext, model.Tags, result);
return result;
}
}
/// <summary> /// <summary>
/// Patch-style update. A null field is left alone; an empty string clears it. Passing a /// Patch-style update. A null field is left alone; an empty string clears it. Passing a
/// <see cref="Tags"/> list replaces the beat's tags outright. /// <see cref="Tags"/> list replaces the beat's tags outright.
@@ -38,6 +56,44 @@ public record UpdateBeatRequest(
Guid? SceneId = null, Guid? SceneId = null,
IReadOnlyList<string>? Tags = null); IReadOnlyList<string>? Tags = null);
public class UpdateBeatRequestValidator : IModelValidator<UpdateBeatRequest>
{
public ValidationResult Validate(UpdateBeatRequest model)
{
var result = new ValidationResult();
if (model.Title is not null)
{
if (model.Title.Length == 0)
result.AddError("Title", "'Title' can not be cleared — a beat always needs one.");
else if (model.Title.Length > 200)
result.AddError("Title", "'Title' must be 200 characters or fewer.");
}
BeatValidation.OptionalFields(model.SortOrder, model.WhatHappened, model.WhatsNext, model.Tags, result);
return result;
}
}
file static class BeatValidation
{
public static void OptionalFields(int? sortOrder, string? whatHappened, string? whatsNext, IReadOnlyList<string>? tags, ValidationResult result)
{
if (sortOrder is < 0)
result.AddError("SortOrder", "'Sort Order' must be zero or greater.");
if (whatHappened is { Length: > 20000 })
result.AddError("WhatHappened", "'What Happened' must be 20,000 characters or fewer.");
if (whatsNext is { Length: > 20000 })
result.AddError("WhatsNext", "'Whats Next' must be 20,000 characters or fewer.");
if (tags is not null && tags.Any(string.IsNullOrWhiteSpace))
result.AddError("Tags", "'Tags' must not contain blank entries.");
}
}
/// <summary> /// <summary>
/// A beat this character appears in, carrying enough of its chapter to link straight to /// A beat this character appears in, carrying enough of its chapter to link straight to
/// the row in that chapter's outline. /// the row in that chapter's outline.
@@ -57,6 +113,19 @@ public record CharacterBeatDto(
/// <summary>Reorders a chapter's beats in one call, by listing their ids in the order wanted.</summary> /// <summary>Reorders a chapter's beats in one call, by listing their ids in the order wanted.</summary>
public record ReorderBeatsRequest(IReadOnlyList<Guid> BeatIds); public record ReorderBeatsRequest(IReadOnlyList<Guid> BeatIds);
public class ReorderBeatsRequestValidator : IModelValidator<ReorderBeatsRequest>
{
public ValidationResult Validate(ReorderBeatsRequest model)
{
var result = new ValidationResult();
if (model.BeatIds is null || model.BeatIds.Count == 0)
result.AddError("BeatIds", "'Beat Ids' must not be empty.");
return result;
}
}
public static class BeatMapping public static class BeatMapping
{ {
public static BeatDto ToDto(this Beat b) => new( public static BeatDto ToDto(this Beat b) => new(
+11 -9
View File
@@ -1,4 +1,5 @@
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Beats; namespace Novelly.Api.Beats;
@@ -6,7 +7,9 @@ public static class BeatEndpoints
{ {
public static IEndpointRouteBuilder MapBeatEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapBeatEndpoints(this IEndpointRouteBuilder app)
{ {
var chapterScoped = app.MapGroup("/api/chapters/{chapterId:guid}/beats").WithTags("Beats").AddEndpointFilter<RequestLoggingEndpointFilter>(); var chapterScoped = app.MapGroup("/api/chapters/{chapterId:guid}/beats").WithTags("Beats")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
chapterScoped.MapGet("/", async (Guid chapterId, BeatService service, CancellationToken ct) => chapterScoped.MapGet("/", async (Guid chapterId, BeatService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(chapterId, ct))) Results.Ok(await service.ListAsync(chapterId, ct)))
@@ -27,26 +30,25 @@ public static class BeatEndpoints
app.MapGet("/api/characters/{characterId:guid}/beats", async ( app.MapGet("/api/characters/{characterId:guid}/beats", async (
Guid characterId, BeatService service, CancellationToken ct) => Guid characterId, BeatService service, CancellationToken ct) =>
Results.Ok(await service.ListForCharacterAsync(characterId, ct))) (await service.ListForCharacterAsync(characterId, ct)).ToApiResult())
.WithTags("Beats") .WithTags("Beats")
.WithSummary("Every beat this character appears in, in manuscript order."); .WithSummary("Every beat this character appears in, in manuscript order.");
var beats = app.MapGroup("/api/beats").WithTags("Beats").AddEndpointFilter<RequestLoggingEndpointFilter>(); var beats = app.MapGroup("/api/beats").WithTags("Beats")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
beats.MapGet("/{id:guid}", async (Guid id, BeatService service, CancellationToken ct) => beats.MapGet("/{id:guid}", async (Guid id, BeatService service, CancellationToken ct) =>
Results.Ok(await service.GetAsync(id, ct))) (await service.GetAsync(id, ct)).ToApiResult())
.WithSummary("Read one beat."); .WithSummary("Read one beat.");
beats.MapPatch("/{id:guid}", async ( beats.MapPatch("/{id:guid}", async (
Guid id, UpdateBeatRequest request, BeatService service, CancellationToken ct) => Guid id, UpdateBeatRequest request, BeatService service, CancellationToken ct) =>
Results.Ok(await service.UpdateAsync(id, request, ct))) (await service.UpdateAsync(id, request, ct)).ToApiResult())
.WithSummary("Update a beat. Sending a tag list replaces the beat's tags."); .WithSummary("Update a beat. Sending a tag list replaces the beat's tags.");
beats.MapDelete("/{id:guid}", async (Guid id, BeatService service, CancellationToken ct) => beats.MapDelete("/{id:guid}", async (Guid id, BeatService service, CancellationToken ct) =>
{ await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
await service.DeleteAsync(id, ct);
return Results.NoContent();
})
.WithSummary("Delete a beat."); .WithSummary("Delete a beat.");
return app; return app;
+64 -17
View File
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data; using Novelly.Api.Data;
using Novelly.Api.Tags; 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 /// Beats are a chapter's outline: a flat, ordered table rather than a tree. Everything
/// here is scoped to one chapter. /// here is scoped to one chapter.
/// </summary> /// </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) public async Task<IReadOnlyList<BeatDto>> ListAsync(Guid chapterId, CancellationToken ct = default)
{ {
Guard.Default(chapterId, nameof(chapterId));
logger.LogInformation("Listing beats for chapter {ChapterId}", chapterId); logger.LogInformation("Listing beats for chapter {ChapterId}", chapterId);
var beats = await Query() var beats = await Query()
@@ -25,26 +34,32 @@ public class BeatService(INovelDbContext db, TagService tags, ILogger<BeatServic
return [.. beats.Select(b => b.ToDto())]; 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); logger.LogInformation("Getting beat {BeatId}", id);
return (await FindAsync(id, ct)).ToDto(); return (await FindAsync(id, ct))?.ToDto();
} }
/// <summary> /// <summary>
/// Every beat this character appears in, in manuscript order. This is the character /// 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 /// 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> /// </summary>
public async Task<IReadOnlyList<CharacterBeatDto>> ListForCharacterAsync( public async Task<IReadOnlyList<CharacterBeatDto>?> ListForCharacterAsync(
Guid characterId, CancellationToken ct = default) Guid characterId, CancellationToken ct = default)
{ {
Guard.Default(characterId, nameof(characterId));
logger.LogInformation("Listing beats for character {CharacterId}", characterId); logger.LogInformation("Listing beats for character {CharacterId}", characterId);
if (!await db.Characters.AnyAsync(c => c.Id == characterId, ct)) if (!await db.Characters.AnyAsync(c => c.Id == characterId, ct))
{ {
logger.LogWarning("Character {CharacterId} not found", characterId); logger.LogInformation("Character {CharacterId} not found", characterId);
throw new NotFoundException(nameof(Character), characterId); return null;
} }
var beats = await db.Beats 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) 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); logger.LogInformation("Creating beat {Title} for chapter {ChapterId}", request.Title, chapterId);
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct); var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct);
if (chapter is null) 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); throw new NotFoundException(nameof(Chapter), chapterId);
} }
@@ -103,18 +122,31 @@ public class BeatService(INovelDbContext db, TagService tags, ILogger<BeatServic
db.Beats.Add(beat); db.Beats.Add(beat);
await db.SaveChangesAsync(ct); 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); logger.LogInformation("Updating beat {BeatId}", id);
var beat = await FindAsync(id, ct); var beat = await FindAsync(id, ct);
if (beat is null)
{
return null;
}
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct); var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct);
if (chapter is null) 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); throw new NotFoundException(nameof(Chapter), beat.ChapterId);
} }
@@ -134,16 +166,25 @@ public class BeatService(INovelDbContext db, TagService tags, ILogger<BeatServic
} }
await db.SaveChangesAsync(ct); 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); logger.LogInformation("Deleting beat {BeatId}", id);
var beat = await FindAsync(id, ct); var beat = await FindAsync(id, ct);
if (beat is null)
{
return false;
}
db.Beats.Remove(beat); db.Beats.Remove(beat);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return true;
} }
/// <summary> /// <summary>
@@ -153,6 +194,10 @@ public class BeatService(INovelDbContext db, TagService tags, ILogger<BeatServic
public async Task<IReadOnlyList<BeatDto>> ReorderAsync( public async Task<IReadOnlyList<BeatDto>> ReorderAsync(
Guid chapterId, ReorderBeatsRequest request, CancellationToken ct = default) 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); logger.LogInformation("Reordering {Count} beats for chapter {ChapterId}", request.BeatIds.Count, chapterId);
var beats = await db.Beats.Where(b => b.ChapterId == chapterId).ToListAsync(ct); 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.Scene)
.Include(b => b.Tags); .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); logger.LogDebug("Finding beat {BeatId}", id);
var beat = await Query().FirstOrDefaultAsync(b => b.Id == id, ct); var beat = await Query().FirstOrDefaultAsync(b => b.Id == id, ct);
if (beat is null) if (beat is null)
{ {
logger.LogWarning("Beat {BeatId} not found", id); logger.LogInformation("Beat {BeatId} not found", id);
throw new NotFoundException(nameof(Beat), id); }
else
{
logger.LogDebug("Found beat {BeatId}", id);
} }
logger.LogDebug("Found beat {BeatId}", id);
return beat; return beat;
} }
} }
+63
View File
@@ -1,5 +1,6 @@
using Novelly.Api.Beats; using Novelly.Api.Beats;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Scenes; using Novelly.Api.Scenes;
using Novelly.Api.Tags; using Novelly.Api.Tags;
@@ -53,6 +54,23 @@ public record CreateChapterRequest(
int? TargetWordCount = null, int? TargetWordCount = null,
IReadOnlyList<string>? Tags = null); IReadOnlyList<string>? Tags = null);
public class CreateChapterRequestValidator : IModelValidator<CreateChapterRequest>
{
public ValidationResult Validate(CreateChapterRequest model)
{
var result = new ValidationResult();
if (string.IsNullOrWhiteSpace(model.Title))
result.AddError("Title", "'Title' must not be empty.");
else if (model.Title.Length > 200)
result.AddError("Title", "'Title' must be 200 characters or fewer.");
ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Tags, result);
return result;
}
}
/// <summary> /// <summary>
/// Patch-style update. A null field is left alone; an empty string clears it. Passing a /// Patch-style update. A null field is left alone; an empty string clears it. Passing a
/// <see cref="Tags"/> list replaces the chapter's tags outright. /// <see cref="Tags"/> list replaces the chapter's tags outright.
@@ -68,6 +86,51 @@ public record UpdateChapterRequest(
int? TargetWordCount = null, int? TargetWordCount = null,
IReadOnlyList<string>? Tags = null); IReadOnlyList<string>? Tags = null);
public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterRequest>
{
public ValidationResult Validate(UpdateChapterRequest model)
{
var result = new ValidationResult();
if (model.Title is not null)
{
if (model.Title.Length == 0)
result.AddError("Title", "'Title' can not be cleared — a chapter always needs one.");
else if (model.Title.Length > 200)
result.AddError("Title", "'Title' must be 200 characters or fewer.");
}
ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Tags, result);
return result;
}
}
file static class ChapterValidation
{
public static void OptionalFields(
int? number, string? summary, string? setting, string? notes, int? targetWordCount, IReadOnlyList<string>? tags, ValidationResult result)
{
if (number is <= 0)
result.AddError("Number", "'Number' must be greater than zero.");
if (summary is { Length: > 20000 })
result.AddError("Summary", "'Summary' must be 20,000 characters or fewer.");
if (setting is { Length: > 500 })
result.AddError("Setting", "'Setting' must be 500 characters or fewer.");
if (notes is { Length: > 20000 })
result.AddError("Notes", "'Notes' must be 20,000 characters or fewer.");
if (targetWordCount is < 0)
result.AddError("TargetWordCount", "'Target Word Count' must be zero or greater.");
if (tags is not null && tags.Any(string.IsNullOrWhiteSpace))
result.AddError("Tags", "'Tags' must not contain blank entries.");
}
}
public static class ChapterMapping public static class ChapterMapping
{ {
public static ChapterDto ToDto(this Chapter c) => new( public static ChapterDto ToDto(this Chapter c) => new(
+10 -8
View File
@@ -1,4 +1,5 @@
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Chapters; namespace Novelly.Api.Chapters;
@@ -6,7 +7,9 @@ public static class ChapterEndpoints
{ {
public static IEndpointRouteBuilder MapChapterEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapChapterEndpoints(this IEndpointRouteBuilder app)
{ {
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/chapters").WithTags("Chapters").AddEndpointFilter<RequestLoggingEndpointFilter>(); var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/chapters").WithTags("Chapters")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
projectScoped.MapGet("/", async (Guid projectId, ChapterService service, CancellationToken ct) => projectScoped.MapGet("/", async (Guid projectId, ChapterService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(projectId, ct))) Results.Ok(await service.ListAsync(projectId, ct)))
@@ -20,22 +23,21 @@ public static class ChapterEndpoints
}) })
.WithSummary("Add a chapter."); .WithSummary("Add a chapter.");
var chapters = app.MapGroup("/api/chapters").WithTags("Chapters").AddEndpointFilter<RequestLoggingEndpointFilter>(); var chapters = app.MapGroup("/api/chapters").WithTags("Chapters")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
chapters.MapGet("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) => chapters.MapGet("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
Results.Ok(await service.GetAsync(id, ct))) (await service.GetAsync(id, ct)).ToApiResult())
.WithSummary("Read a chapter with all of its scenes."); .WithSummary("Read a chapter with all of its scenes.");
chapters.MapPatch("/{id:guid}", async ( chapters.MapPatch("/{id:guid}", async (
Guid id, UpdateChapterRequest request, ChapterService service, CancellationToken ct) => Guid id, UpdateChapterRequest request, ChapterService service, CancellationToken ct) =>
Results.Ok(await service.UpdateAsync(id, request, ct))) (await service.UpdateAsync(id, request, ct)).ToApiResult())
.WithSummary("Update a chapter."); .WithSummary("Update a chapter.");
chapters.MapDelete("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) => chapters.MapDelete("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
{ await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
await service.DeleteAsync(id, ct);
return Results.NoContent();
})
.WithSummary("Delete a chapter and its scenes."); .WithSummary("Delete a chapter and its scenes.");
return app; return app;
+48 -12
View File
@@ -1,15 +1,23 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data; using Novelly.Api.Data;
using Novelly.Api.Projects; using Novelly.Api.Projects;
using Novelly.Api.Tags; using Novelly.Api.Tags;
namespace Novelly.Api.Chapters; namespace Novelly.Api.Chapters;
public class ChapterService(INovelDbContext db, TagService tags, ILogger<ChapterService> logger) public class ChapterService(
INovelDbContext db,
TagService tags,
ILogger<ChapterService> logger,
IModelValidator<CreateChapterRequest> createValidator,
IModelValidator<UpdateChapterRequest> updateValidator)
{ {
public async Task<IReadOnlyList<ChapterSummaryDto>> ListAsync(Guid projectId, CancellationToken ct = default) public async Task<IReadOnlyList<ChapterSummaryDto>> ListAsync(Guid projectId, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId));
logger.LogInformation("Listing chapters for project {ProjectId}", projectId); logger.LogInformation("Listing chapters for project {ProjectId}", projectId);
var chapters = await db.Chapters var chapters = await db.Chapters
@@ -24,19 +32,26 @@ public class ChapterService(INovelDbContext db, TagService tags, ILogger<Chapter
return [.. chapters.Select(c => c.ToSummaryDto())]; return [.. chapters.Select(c => c.ToSummaryDto())];
} }
public async Task<ChapterDto> GetAsync(Guid id, CancellationToken ct = default) /// <summary>Null when no chapter has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<ChapterDto?> GetAsync(Guid id, CancellationToken ct = default)
{ {
Guard.Default(id, nameof(id));
logger.LogInformation("Getting chapter {ChapterId}", id); logger.LogInformation("Getting chapter {ChapterId}", id);
return (await FindAsync(id, ct)).ToDto(); return (await FindAsync(id, ct))?.ToDto();
} }
public async Task<ChapterDto> CreateAsync(Guid projectId, CreateChapterRequest request, CancellationToken ct = default) public async Task<ChapterDto> CreateAsync(Guid projectId, CreateChapterRequest request, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId));
Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid();
logger.LogInformation("Creating chapter {Title} for project {ProjectId}", request.Title, projectId); logger.LogInformation("Creating chapter {Title} for project {ProjectId}", request.Title, projectId);
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct)) if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{ {
logger.LogWarning("Project {ProjectId} not found", projectId); logger.LogWarning("Rejected chapter creation: project {ProjectId} not found", projectId);
throw new NotFoundException(nameof(Project), projectId); throw new NotFoundException(nameof(Project), projectId);
} }
@@ -60,14 +75,24 @@ public class ChapterService(INovelDbContext db, TagService tags, ILogger<Chapter
db.Chapters.Add(chapter); db.Chapters.Add(chapter);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return (await FindAsync(chapter.Id, ct)).ToDto();
// Just created it — the reload is only to pick up includes, not to check existence.
return (await FindAsync(chapter.Id, ct))!.ToDto();
} }
public async Task<ChapterDto> UpdateAsync(Guid id, UpdateChapterRequest request, CancellationToken ct = default) public async Task<ChapterDto?> UpdateAsync(Guid id, UpdateChapterRequest request, CancellationToken ct = default)
{ {
Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request));
updateValidator.Validate(request).ThrowIfInvalid();
logger.LogInformation("Updating chapter {ChapterId}", id); logger.LogInformation("Updating chapter {ChapterId}", id);
var chapter = await FindAsync(id, ct); var chapter = await FindAsync(id, ct);
if (chapter is null)
{
return null;
}
chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title; chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title;
chapter.Number = request.Number ?? chapter.Number; chapter.Number = request.Number ?? chapter.Number;
@@ -85,16 +110,25 @@ public class ChapterService(INovelDbContext db, TagService tags, ILogger<Chapter
} }
await db.SaveChangesAsync(ct); 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 chapter was deleted; false if no chapter had this id.</summary>
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{ {
Guard.Default(id, nameof(id));
logger.LogInformation("Deleting chapter {ChapterId}", id); logger.LogInformation("Deleting chapter {ChapterId}", id);
var chapter = await FindAsync(id, ct); var chapter = await FindAsync(id, ct);
if (chapter is null)
{
return false;
}
db.Chapters.Remove(chapter); db.Chapters.Remove(chapter);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return true;
} }
private async Task<int> NextChapterNumberAsync(Guid projectId, CancellationToken ct) private async Task<int> NextChapterNumberAsync(Guid projectId, CancellationToken ct)
@@ -110,7 +144,7 @@ public class ChapterService(INovelDbContext db, TagService tags, ILogger<Chapter
return next; return next;
} }
private async Task<Chapter> FindAsync(Guid id, CancellationToken ct) private async Task<Chapter?> FindAsync(Guid id, CancellationToken ct)
{ {
logger.LogDebug("Finding chapter {ChapterId}", id); logger.LogDebug("Finding chapter {ChapterId}", id);
@@ -125,11 +159,13 @@ public class ChapterService(INovelDbContext db, TagService tags, ILogger<Chapter
if (chapter is null) if (chapter is null)
{ {
logger.LogWarning("Chapter {ChapterId} not found", id); logger.LogInformation("Chapter {ChapterId} not found", id);
throw new NotFoundException(nameof(Chapter), id); }
else
{
logger.LogDebug("Found chapter {ChapterId}", id);
} }
logger.LogDebug("Found chapter {ChapterId}", id);
return chapter; return chapter;
} }
} }
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data; using Novelly.Api.Data;
namespace Novelly.Api.Characters; 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 /// 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. /// who turns out to matter gets promoted after the arc is already sketched.
/// </remarks> /// </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) 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); logger.LogInformation("Listing arc stages for character {CharacterId}", characterId);
var stages = await Query() var stages = await Query()
@@ -27,21 +35,28 @@ public class CharacterArcService(INovelDbContext db, ILogger<CharacterArcService
return [.. stages.Select(s => s.ToDto())]; 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); logger.LogInformation("Getting arc stage {ArcStageId}", id);
return (await FindAsync(id, ct)).ToDto(); return (await FindAsync(id, ct))?.ToDto();
} }
public async Task<ArcStageDto> CreateAsync( public async Task<ArcStageDto> CreateAsync(
Guid characterId, CreateArcStageRequest request, CancellationToken ct = default) 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); logger.LogInformation("Creating arc stage {Title} for character {CharacterId}", request.Title, characterId);
var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == characterId, ct); var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == characterId, ct);
if (character is null) 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); throw new NotFoundException(nameof(Character), characterId);
} }
@@ -58,20 +73,32 @@ public class CharacterArcService(INovelDbContext db, ILogger<CharacterArcService
db.CharacterArcStages.Add(stage); db.CharacterArcStages.Add(stage);
await db.SaveChangesAsync(ct); 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) 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); logger.LogInformation("Updating arc stage {ArcStageId}", id);
var stage = await FindAsync(id, ct); var stage = await FindAsync(id, ct);
if (stage is null)
{
return null;
}
var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == stage.CharacterId, ct); var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == stage.CharacterId, ct);
if (character is null) 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); throw new NotFoundException(nameof(Character), stage.CharacterId);
} }
@@ -84,16 +111,25 @@ public class CharacterArcService(INovelDbContext db, ILogger<CharacterArcService
stage.UpdatedAt = DateTimeOffset.UtcNow; stage.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct); 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); logger.LogInformation("Deleting arc stage {ArcStageId}", id);
var stage = await FindAsync(id, ct); var stage = await FindAsync(id, ct);
if (stage is null)
{
return false;
}
db.CharacterArcStages.Remove(stage); db.CharacterArcStages.Remove(stage);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return true;
} }
/// <summary> /// <summary>
@@ -103,6 +139,10 @@ public class CharacterArcService(INovelDbContext db, ILogger<CharacterArcService
public async Task<IReadOnlyList<ArcStageDto>> ReorderAsync( public async Task<IReadOnlyList<ArcStageDto>> ReorderAsync(
Guid characterId, ReorderArcStagesRequest request, CancellationToken ct = default) 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); logger.LogInformation("Reordering {Count} arc stages for character {CharacterId}", request.StageIds.Count, characterId);
var stages = await db.CharacterArcStages 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 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); logger.LogDebug("Finding arc stage {ArcStageId}", id);
var stage = await Query().FirstOrDefaultAsync(s => s.Id == id, ct); var stage = await Query().FirstOrDefaultAsync(s => s.Id == id, ct);
if (stage is null) if (stage is null)
{ {
logger.LogWarning("CharacterArcStage {ArcStageId} not found", id); logger.LogInformation("CharacterArcStage {ArcStageId} not found", id);
throw new NotFoundException(nameof(CharacterArcStage), id); }
else
{
logger.LogDebug("Found arc stage {ArcStageId}", id);
} }
logger.LogDebug("Found arc stage {ArcStageId}", id);
return stage; return stage;
} }
} }
+159
View File
@@ -1,3 +1,4 @@
using Novelly.Api.Common.Validation;
using Novelly.Api.Tags; using Novelly.Api.Tags;
namespace Novelly.Api.Characters; namespace Novelly.Api.Characters;
@@ -52,6 +53,26 @@ public record CreateCharacterRequest(
string? Notes = null, string? Notes = null,
IReadOnlyList<string>? Tags = null); IReadOnlyList<string>? Tags = null);
public class CreateCharacterRequestValidator : IModelValidator<CreateCharacterRequest>
{
public ValidationResult Validate(CreateCharacterRequest model)
{
var result = new ValidationResult();
if (string.IsNullOrWhiteSpace(model.Name))
result.AddError("Name", "'Name' must not be empty.");
else if (model.Name.Length > 200)
result.AddError("Name", "'Name' must be 200 characters or fewer.");
CharacterValidation.OptionalFields(
model.Age, model.Pronouns, model.Occupation, model.Appearance, model.Personality, model.Backstory,
model.Want, model.Need, model.InternalConflict, model.ExternalConflict, model.ArcSummary, model.Voice,
model.Notes, model.Tags, result);
return result;
}
}
/// <summary> /// <summary>
/// Patch-style update. A null field is left alone; an empty string clears it. Passing a /// Patch-style update. A null field is left alone; an empty string clears it. Passing a
/// <see cref="Tags"/> list replaces the character's tags outright. /// <see cref="Tags"/> list replaces the character's tags outright.
@@ -75,11 +96,87 @@ public record UpdateCharacterRequest(
string? Notes = null, string? Notes = null,
IReadOnlyList<string>? Tags = null); IReadOnlyList<string>? Tags = null);
public class UpdateCharacterRequestValidator : IModelValidator<UpdateCharacterRequest>
{
public ValidationResult Validate(UpdateCharacterRequest model)
{
var result = new ValidationResult();
if (model.Name is not null)
{
if (model.Name.Length == 0)
result.AddError("Name", "'Name' can not be cleared — a character always needs one.");
else if (model.Name.Length > 200)
result.AddError("Name", "'Name' must be 200 characters or fewer.");
}
CharacterValidation.OptionalFields(
model.Age, model.Pronouns, model.Occupation, model.Appearance, model.Personality, model.Backstory,
model.Want, model.Need, model.InternalConflict, model.ExternalConflict, model.ArcSummary, model.Voice,
model.Notes, model.Tags, result);
return result;
}
}
file static class CharacterValidation
{
public static void OptionalFields(
string? age, string? pronouns, string? occupation, string? appearance, string? personality, string? backstory,
string? want, string? need, string? internalConflict, string? externalConflict, string? arcSummary, string? voice,
string? notes, IReadOnlyList<string>? tags, ValidationResult result)
{
Cap(age, "Age", 100, result);
Cap(pronouns, "Pronouns", 100, result);
Cap(occupation, "Occupation", 200, result);
Cap(appearance, "Appearance", 20000, result);
Cap(personality, "Personality", 20000, result);
Cap(backstory, "Backstory", 20000, result);
Cap(want, "Want", 2000, result);
Cap(need, "Need", 2000, result);
Cap(internalConflict, "InternalConflict", 2000, result);
Cap(externalConflict, "ExternalConflict", 2000, result);
Cap(arcSummary, "ArcSummary", 20000, result);
Cap(voice, "Voice", 2000, result);
Cap(notes, "Notes", 20000, result);
if (tags is not null && tags.Any(string.IsNullOrWhiteSpace))
result.AddError("Tags", "'Tags' must not contain blank entries.");
}
private static void Cap(string? value, string field, int max, ValidationResult result)
{
if (value is { Length: var length } && length > max)
result.AddError(field, $"'{field}' must be {max:N0} characters or fewer.");
}
}
public record CreateRelationshipRequest( public record CreateRelationshipRequest(
Guid RelatedCharacterId, Guid RelatedCharacterId,
string RelationshipType, string RelationshipType,
string? Description = null); string? Description = null);
public class CreateRelationshipRequestValidator : IModelValidator<CreateRelationshipRequest>
{
public ValidationResult Validate(CreateRelationshipRequest model)
{
var result = new ValidationResult();
if (model.RelatedCharacterId == Guid.Empty)
result.AddError("RelatedCharacterId", "'Related Character Id' must not be empty.");
if (string.IsNullOrWhiteSpace(model.RelationshipType))
result.AddError("RelationshipType", "'Relationship Type' must not be empty.");
else if (model.RelationshipType.Length > 100)
result.AddError("RelationshipType", "'Relationship Type' must be 100 characters or fewer.");
if (model.Description is { Length: > 2000 })
result.AddError("Description", "'Description' must be 2,000 characters or fewer.");
return result;
}
}
public record ArcStageDto( public record ArcStageDto(
Guid Id, Guid Id,
Guid CharacterId, Guid CharacterId,
@@ -97,6 +194,23 @@ public record CreateArcStageRequest(
string? Description = null, string? Description = null,
Guid? ChapterId = null); Guid? ChapterId = null);
public class CreateArcStageRequestValidator : IModelValidator<CreateArcStageRequest>
{
public ValidationResult Validate(CreateArcStageRequest model)
{
var result = new ValidationResult();
if (string.IsNullOrWhiteSpace(model.Title))
result.AddError("Title", "'Title' must not be empty.");
else if (model.Title.Length > 200)
result.AddError("Title", "'Title' must be 200 characters or fewer.");
ArcStageValidation.OptionalFields(model.SortOrder, model.Description, result);
return result;
}
}
/// <summary>Patch-style update. A null field is left alone; an empty string clears it.</summary> /// <summary>Patch-style update. A null field is left alone; an empty string clears it.</summary>
public record UpdateArcStageRequest( public record UpdateArcStageRequest(
string? Title = null, string? Title = null,
@@ -104,9 +218,54 @@ public record UpdateArcStageRequest(
string? Description = null, string? Description = null,
Guid? ChapterId = null); Guid? ChapterId = null);
public class UpdateArcStageRequestValidator : IModelValidator<UpdateArcStageRequest>
{
public ValidationResult Validate(UpdateArcStageRequest model)
{
var result = new ValidationResult();
if (model.Title is not null)
{
if (model.Title.Length == 0)
result.AddError("Title", "'Title' can not be cleared — an arc stage always needs one.");
else if (model.Title.Length > 200)
result.AddError("Title", "'Title' must be 200 characters or fewer.");
}
ArcStageValidation.OptionalFields(model.SortOrder, model.Description, result);
return result;
}
}
file static class ArcStageValidation
{
public static void OptionalFields(int? sortOrder, string? description, ValidationResult result)
{
if (sortOrder is < 0)
result.AddError("SortOrder", "'Sort Order' must be zero or greater.");
if (description is { Length: > 20000 })
result.AddError("Description", "'Description' must be 20,000 characters or fewer.");
}
}
/// <summary>Reorders a character's arc in one call, by listing the stage ids in the order wanted.</summary> /// <summary>Reorders a character's arc in one call, by listing the stage ids in the order wanted.</summary>
public record ReorderArcStagesRequest(IReadOnlyList<Guid> StageIds); public record ReorderArcStagesRequest(IReadOnlyList<Guid> StageIds);
public class ReorderArcStagesRequestValidator : IModelValidator<ReorderArcStagesRequest>
{
public ValidationResult Validate(ReorderArcStagesRequest model)
{
var result = new ValidationResult();
if (model.StageIds is null || model.StageIds.Count == 0)
result.AddError("StageIds", "'Stage Ids' must not be empty.");
return result;
}
}
public static class CharacterMapping public static class CharacterMapping
{ {
@@ -1,4 +1,5 @@
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Characters; namespace Novelly.Api.Characters;
@@ -6,7 +7,9 @@ public static class CharacterEndpoints
{ {
public static IEndpointRouteBuilder MapCharacterEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapCharacterEndpoints(this IEndpointRouteBuilder app)
{ {
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/characters").WithTags("Characters").AddEndpointFilter<RequestLoggingEndpointFilter>(); var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/characters").WithTags("Characters")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
projectScoped.MapGet("/", async (Guid projectId, CharacterService service, CancellationToken ct) => projectScoped.MapGet("/", async (Guid projectId, CharacterService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(projectId, ct))) Results.Ok(await service.ListAsync(projectId, ct)))
@@ -20,35 +23,31 @@ public static class CharacterEndpoints
}) })
.WithSummary("Add a character dossier."); .WithSummary("Add a character dossier.");
var characters = app.MapGroup("/api/characters").WithTags("Characters").AddEndpointFilter<RequestLoggingEndpointFilter>(); var characters = app.MapGroup("/api/characters").WithTags("Characters")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
characters.MapGet("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) => characters.MapGet("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) =>
Results.Ok(await service.GetAsync(id, ct))) (await service.GetAsync(id, ct)).ToApiResult())
.WithSummary("Read a character dossier."); .WithSummary("Read a character dossier.");
characters.MapPatch("/{id:guid}", async ( characters.MapPatch("/{id:guid}", async (
Guid id, UpdateCharacterRequest request, CharacterService service, CancellationToken ct) => Guid id, UpdateCharacterRequest request, CharacterService service, CancellationToken ct) =>
Results.Ok(await service.UpdateAsync(id, request, ct))) (await service.UpdateAsync(id, request, ct)).ToApiResult())
.WithSummary("Update a character dossier."); .WithSummary("Update a character dossier.");
characters.MapDelete("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) => characters.MapDelete("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) =>
{ await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
await service.DeleteAsync(id, ct);
return Results.NoContent();
})
.WithSummary("Delete a character."); .WithSummary("Delete a character.");
characters.MapPost("/{id:guid}/relationships", async ( characters.MapPost("/{id:guid}/relationships", async (
Guid id, CreateRelationshipRequest request, CharacterService service, CancellationToken ct) => Guid id, CreateRelationshipRequest request, CharacterService service, CancellationToken ct) =>
Results.Ok(await service.AddRelationshipAsync(id, request, ct))) (await service.AddRelationshipAsync(id, request, ct)).ToApiResult())
.WithSummary("Relate this character to another in the same project."); .WithSummary("Relate this character to another in the same project.");
characters.MapDelete("/relationships/{relationshipId:guid}", async ( characters.MapDelete("/relationships/{relationshipId:guid}", async (
Guid relationshipId, CharacterService service, CancellationToken ct) => Guid relationshipId, CharacterService service, CancellationToken ct) =>
{ await service.RemoveRelationshipAsync(relationshipId, ct) ? Results.NoContent() : Results.NotFound())
await service.RemoveRelationshipAsync(relationshipId, ct);
return Results.NoContent();
})
.WithSummary("Remove a relationship."); .WithSummary("Remove a relationship.");
characters.MapGet("/{id:guid}/arc", async ( characters.MapGet("/{id:guid}/arc", async (
@@ -69,22 +68,21 @@ public static class CharacterEndpoints
Results.Ok(await service.ReorderAsync(id, request, ct))) Results.Ok(await service.ReorderAsync(id, request, ct)))
.WithSummary("Renumber a character's arc to match the order given."); .WithSummary("Renumber a character's arc to match the order given.");
var arcStages = app.MapGroup("/api/arc-stages").WithTags("Characters").AddEndpointFilter<RequestLoggingEndpointFilter>(); var arcStages = app.MapGroup("/api/arc-stages").WithTags("Characters")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
arcStages.MapGet("/{id:guid}", async (Guid id, CharacterArcService service, CancellationToken ct) => arcStages.MapGet("/{id:guid}", async (Guid id, CharacterArcService service, CancellationToken ct) =>
Results.Ok(await service.GetAsync(id, ct))) (await service.GetAsync(id, ct)).ToApiResult())
.WithSummary("Read one arc stage."); .WithSummary("Read one arc stage.");
arcStages.MapPatch("/{id:guid}", async ( arcStages.MapPatch("/{id:guid}", async (
Guid id, UpdateArcStageRequest request, CharacterArcService service, CancellationToken ct) => Guid id, UpdateArcStageRequest request, CharacterArcService service, CancellationToken ct) =>
Results.Ok(await service.UpdateAsync(id, request, ct))) (await service.UpdateAsync(id, request, ct)).ToApiResult())
.WithSummary("Update an arc stage."); .WithSummary("Update an arc stage.");
arcStages.MapDelete("/{id:guid}", async (Guid id, CharacterArcService service, CancellationToken ct) => arcStages.MapDelete("/{id:guid}", async (Guid id, CharacterArcService service, CancellationToken ct) =>
{ await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
await service.DeleteAsync(id, ct);
return Results.NoContent();
})
.WithSummary("Delete an arc stage."); .WithSummary("Delete an arc stage.");
return app; return app;
+68 -18
View File
@@ -1,12 +1,19 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data; using Novelly.Api.Data;
using Novelly.Api.Projects; using Novelly.Api.Projects;
using Novelly.Api.Tags; using Novelly.Api.Tags;
namespace Novelly.Api.Characters; namespace Novelly.Api.Characters;
public class CharacterService(INovelDbContext db, TagService tags, ILogger<CharacterService> logger) public class CharacterService(
INovelDbContext db,
TagService tags,
ILogger<CharacterService> logger,
IModelValidator<CreateCharacterRequest> createValidator,
IModelValidator<UpdateCharacterRequest> updateValidator,
IModelValidator<CreateRelationshipRequest> relationshipValidator)
{ {
/// <summary> /// <summary>
/// Main characters first, then by the part they play, then by name. /// Main characters first, then by the part they play, then by name.
@@ -20,6 +27,8 @@ public class CharacterService(INovelDbContext db, TagService tags, ILogger<Chara
/// </remarks> /// </remarks>
public async Task<IReadOnlyList<CharacterDto>> ListAsync(Guid projectId, CancellationToken ct = default) public async Task<IReadOnlyList<CharacterDto>> ListAsync(Guid projectId, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId));
logger.LogInformation("Listing characters for project {ProjectId}", projectId); logger.LogInformation("Listing characters for project {ProjectId}", projectId);
var characters = await Query() var characters = await Query()
@@ -36,14 +45,21 @@ public class CharacterService(INovelDbContext db, TagService tags, ILogger<Chara
]; ];
} }
public async Task<CharacterDto> GetAsync(Guid id, CancellationToken ct = default) /// <summary>Null when no character has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<CharacterDto?> GetAsync(Guid id, CancellationToken ct = default)
{ {
Guard.Default(id, nameof(id));
logger.LogInformation("Getting character {CharacterId}", id); logger.LogInformation("Getting character {CharacterId}", id);
return (await FindAsync(id, ct)).ToDto(); return (await FindAsync(id, ct))?.ToDto();
} }
public async Task<CharacterDto> CreateAsync(Guid projectId, CreateCharacterRequest request, CancellationToken ct = default) public async Task<CharacterDto> CreateAsync(Guid projectId, CreateCharacterRequest request, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId));
Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid();
logger.LogInformation("Creating character {Name} for project {ProjectId}, role {Role}, importance {Importance}", request.Name, projectId, request.Role, request.Importance); logger.LogInformation("Creating character {Name} for project {ProjectId}, role {Role}, importance {Importance}", request.Name, projectId, request.Role, request.Importance);
await EnsureProjectExists(projectId, ct); await EnsureProjectExists(projectId, ct);
@@ -76,14 +92,24 @@ public class CharacterService(INovelDbContext db, TagService tags, ILogger<Chara
db.Characters.Add(character); db.Characters.Add(character);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return (await FindAsync(character.Id, ct)).ToDto();
// Just created it — the reload is only to pick up includes, not to check existence.
return (await FindAsync(character.Id, ct))!.ToDto();
} }
public async Task<CharacterDto> UpdateAsync(Guid id, UpdateCharacterRequest request, CancellationToken ct = default) public async Task<CharacterDto?> UpdateAsync(Guid id, UpdateCharacterRequest request, CancellationToken ct = default)
{ {
Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request));
updateValidator.Validate(request).ThrowIfInvalid();
logger.LogInformation("Updating character {CharacterId}", id); logger.LogInformation("Updating character {CharacterId}", id);
var character = await FindAsync(id, ct); var character = await FindAsync(id, ct);
if (character is null)
{
return null;
}
character.Name = Patch.Apply(character.Name, request.Name) ?? character.Name; character.Name = Patch.Apply(character.Name, request.Name) ?? character.Name;
character.Role = request.Role ?? character.Role; character.Role = request.Role ?? character.Role;
@@ -109,29 +135,47 @@ public class CharacterService(INovelDbContext db, TagService tags, ILogger<Chara
} }
await db.SaveChangesAsync(ct); 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 character was deleted; false if no character had this id.</summary>
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{ {
Guard.Default(id, nameof(id));
logger.LogInformation("Deleting character {CharacterId}", id); logger.LogInformation("Deleting character {CharacterId}", id);
var character = await FindAsync(id, ct); var character = await FindAsync(id, ct);
if (character is null)
{
return false;
}
db.Characters.Remove(character); db.Characters.Remove(character);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return true;
} }
public async Task<CharacterDto> AddRelationshipAsync( /// <summary>Null when the subject character (<paramref name="characterId"/>) doesn't exist.</summary>
public async Task<CharacterDto?> AddRelationshipAsync(
Guid characterId, CreateRelationshipRequest request, CancellationToken ct = default) Guid characterId, CreateRelationshipRequest request, CancellationToken ct = default)
{ {
Guard.Default(characterId, nameof(characterId));
Guard.Null(request, nameof(request));
relationshipValidator.Validate(request).ThrowIfInvalid();
logger.LogInformation("Adding relationship {RelationshipType} from character {CharacterId} to {RelatedCharacterId}", request.RelationshipType, characterId, request.RelatedCharacterId); logger.LogInformation("Adding relationship {RelationshipType} from character {CharacterId} to {RelatedCharacterId}", request.RelationshipType, characterId, request.RelatedCharacterId);
var character = await FindAsync(characterId, ct); var character = await FindAsync(characterId, ct);
if (character is null)
{
return null;
}
var related = await db.Characters.FirstOrDefaultAsync(c => c.Id == request.RelatedCharacterId, ct); var related = await db.Characters.FirstOrDefaultAsync(c => c.Id == request.RelatedCharacterId, ct);
if (related is null) if (related is null)
{ {
logger.LogWarning("Character {RelatedCharacterId} not found", request.RelatedCharacterId); logger.LogWarning("Rejected relationship: related character {RelatedCharacterId} not found", request.RelatedCharacterId);
throw new NotFoundException(nameof(Character), request.RelatedCharacterId); throw new NotFoundException(nameof(Character), request.RelatedCharacterId);
} }
@@ -150,22 +194,26 @@ public class CharacterService(INovelDbContext db, TagService tags, ILogger<Chara
}); });
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return (await FindAsync(characterId, ct)).ToDto(); return (await FindAsync(characterId, ct))!.ToDto();
} }
public async Task RemoveRelationshipAsync(Guid relationshipId, CancellationToken ct = default) /// <summary>True if a relationship was removed; false if no relationship had this id.</summary>
public async Task<bool> RemoveRelationshipAsync(Guid relationshipId, CancellationToken ct = default)
{ {
Guard.Default(relationshipId, nameof(relationshipId));
logger.LogInformation("Removing relationship {RelationshipId}", relationshipId); logger.LogInformation("Removing relationship {RelationshipId}", relationshipId);
var relationship = await db.CharacterRelationships.FirstOrDefaultAsync(r => r.Id == relationshipId, ct); var relationship = await db.CharacterRelationships.FirstOrDefaultAsync(r => r.Id == relationshipId, ct);
if (relationship is null) if (relationship is null)
{ {
logger.LogWarning("CharacterRelationship {RelationshipId} not found", relationshipId); logger.LogInformation("CharacterRelationship {RelationshipId} not found", relationshipId);
throw new NotFoundException(nameof(CharacterRelationship), relationshipId); return false;
} }
db.CharacterRelationships.Remove(relationship); db.CharacterRelationships.Remove(relationship);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return true;
} }
private IQueryable<Character> Query() => private IQueryable<Character> Query() =>
@@ -176,18 +224,20 @@ public class CharacterService(INovelDbContext db, TagService tags, ILogger<Chara
.Include(c => c.ArcStages) .Include(c => c.ArcStages)
.ThenInclude(s => s.Chapter); .ThenInclude(s => s.Chapter);
private async Task<Character> FindAsync(Guid id, CancellationToken ct) private async Task<Character?> FindAsync(Guid id, CancellationToken ct)
{ {
logger.LogDebug("Finding character {CharacterId}", id); logger.LogDebug("Finding character {CharacterId}", id);
var character = await Query().FirstOrDefaultAsync(c => c.Id == id, ct); var character = await Query().FirstOrDefaultAsync(c => c.Id == id, ct);
if (character is null) if (character is null)
{ {
logger.LogWarning("Character {CharacterId} not found", id); logger.LogInformation("Character {CharacterId} not found", id);
throw new NotFoundException(nameof(Character), id); }
else
{
logger.LogDebug("Found character {CharacterId}", id);
} }
logger.LogDebug("Found character {CharacterId}", id);
return character; return character;
} }
@@ -197,7 +247,7 @@ public class CharacterService(INovelDbContext db, TagService tags, ILogger<Chara
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct)) if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{ {
logger.LogWarning("Project {ProjectId} not found", projectId); logger.LogWarning("Rejected character creation: project {ProjectId} not found", projectId);
throw new NotFoundException(nameof(Project), projectId); throw new NotFoundException(nameof(Project), projectId);
} }
} }
@@ -0,0 +1,12 @@
namespace Novelly.Api.Common;
public static class ApiResultExtensions
{
/// <summary>
/// A missing entity is not exceptional, so lookups return null instead of throwing.
/// This is where that null finally becomes an HTTP 404 — the one place the API layer
/// needs to know about it.
/// </summary>
public static IResult ToApiResult<T>(this T? value) where T : class =>
value is null ? Results.NotFound() : Results.Ok(value);
}
+40
View File
@@ -0,0 +1,40 @@
namespace Novelly.Api.Common;
public static class Guard
{
public static void Null<T>(T t, string parameterName) where T : class
{
if (t is null)
throw new ArgumentNullException(parameterName, $"{nameof(parameterName)} can not be null");
}
public static void Empty(string value, string parameterName)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException($"{parameterName} can not be empty", parameterName);
}
public static void Empty<T>(IEnumerable<T> collection, string parameterName)
{
if (collection == null || !collection.Any())
throw new ArgumentException($"{parameterName} can not be empty", parameterName);
}
public static void Negative(int value, string parameterName)
{
if (value < 0)
throw new ArgumentOutOfRangeException(parameterName, $"{parameterName} must be a positive number or zero");
}
public static void NegativeOrZero(int value, string parameterName)
{
if (value <= 0)
throw new ArgumentOutOfRangeException(parameterName, $"{nameof(parameterName)} must be a positive number greater then zero");
}
public static void Default<T>(T value, string parameterName)
{
if (EqualityComparer<T>.Default.Equals(value, default))
throw new ArgumentException($"{parameterName} can not be a default value", parameterName);
}
}
@@ -3,6 +3,7 @@ using Novelly.Api.Agent;
using Novelly.Api.Beats; using Novelly.Api.Beats;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data; using Novelly.Api.Data;
using Novelly.Api.Projects; using Novelly.Api.Projects;
using Novelly.Api.Questions; using Novelly.Api.Questions;
@@ -40,6 +41,8 @@ public static class NovellyServiceRegistration
services.Configure<AgentOptions>(configuration.GetSection(AgentOptions.SectionName)); services.Configure<AgentOptions>(configuration.GetSection(AgentOptions.SectionName));
services.AddScoped<IAgentModelClient, AnthropicAgentModelClient>(); services.AddScoped<IAgentModelClient, AnthropicAgentModelClient>();
services.AddModelValidatorsFromAssemblyContaining<Program>();
return services; return services;
} }
} }
@@ -0,0 +1,13 @@
namespace Novelly.Api.Common.Validation;
public interface IModelValidator
{
ValidationResult Validate(object model);
}
public interface IModelValidator<in T> : IModelValidator
{
ValidationResult Validate(T model);
ValidationResult IModelValidator.Validate(object model) => Validate((T)model);
}
@@ -0,0 +1,18 @@
namespace Novelly.Api.Common.Validation;
public static class ModelValidatorServiceCollectionExtensions
{
public static IServiceCollection AddModelValidatorsFromAssemblyContaining<TMarker>(this IServiceCollection services)
{
var registrations = typeof(TMarker).Assembly.GetTypes()
.Where(type => !type.IsAbstract && !type.IsInterface)
.SelectMany(type => type.GetInterfaces()
.Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IModelValidator<>))
.Select(i => (Interface: i, Implementation: type)));
foreach (var (@interface, implementation) in registrations)
services.AddScoped(@interface, implementation);
return services;
}
}
@@ -0,0 +1,38 @@
namespace Novelly.Api.Common.Validation;
/// <summary>
/// Minimal-API equivalent of mic-check's MVC <c>ModelValidationActionFilter</c>. Runs every
/// endpoint argument that has a registered <see cref="IModelValidator{T}"/> through it and,
/// if any fail, short-circuits with a 400 naming every field and message a caller can act on.
/// </summary>
public class ValidationEndpointFilter : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
var errors = new Dictionary<string, string[]>();
foreach (var argument in context.Arguments)
{
if (argument is null) continue;
var validatorType = typeof(IModelValidator<>).MakeGenericType(argument.GetType());
if (context.HttpContext.RequestServices.GetService(validatorType) is not IModelValidator validator) continue;
var result = validator.Validate(argument);
if (result.IsInvalid)
{
foreach (var group in result.Errors.GroupBy(e => e.PropertyName))
{
errors[group.Key] = [.. group.Select(e => e.Message)];
}
}
}
if (errors.Count > 0)
{
return Results.ValidationProblem(errors);
}
return await next(context);
}
}
@@ -0,0 +1,14 @@
namespace Novelly.Api.Common.Validation;
public record ValidationError(string PropertyName, string Message);
public class ValidationResult
{
private readonly List<ValidationError> _errors = [];
public IReadOnlyList<ValidationError> Errors => _errors;
public bool IsValid => _errors.Count == 0;
public bool IsInvalid => _errors.Count > 0;
public void AddError(string propertyName, string message) => _errors.Add(new ValidationError(propertyName, message));
}
@@ -0,0 +1,17 @@
namespace Novelly.Api.Common.Validation;
public static class ValidationResultExtensions
{
/// <summary>
/// The service-level half of "validate again and throw if invalid": callers that reach
/// a service directly (agent tools, MCP, tests) skip the API's <see cref="ValidationEndpointFilter"/>,
/// so services re-run the same validator and throw rather than act on bad data.
/// </summary>
public static void ThrowIfInvalid(this ValidationResult result)
{
if (result.IsInvalid)
{
throw new ArgumentException(string.Join("; ", result.Errors.Select(e => $"{e.PropertyName}: {e.Message}")));
}
}
}
+68
View File
@@ -1,3 +1,5 @@
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Projects; namespace Novelly.Api.Projects;
public record ProjectSummaryDto( public record ProjectSummaryDto(
@@ -33,6 +35,19 @@ public record CreateProjectRequest(
string? Notes = null, string? Notes = null,
int? TargetWordCount = null); int? TargetWordCount = null);
public class CreateProjectRequestValidator : IModelValidator<CreateProjectRequest>
{
public ValidationResult Validate(CreateProjectRequest model)
{
var result = new ValidationResult();
ProjectValidation.Title(model.Title, result);
ProjectValidation.OptionalFields(model.Author, model.Genre, model.Logline, model.Synopsis, model.Notes, model.TargetWordCount, result);
return result;
}
}
/// <summary> /// <summary>
/// Patch-style update: every field is optional and null means "leave alone". /// Patch-style update: every field is optional and null means "leave alone".
/// Clearing a field is done by sending an empty string. /// Clearing a field is done by sending an empty string.
@@ -46,6 +61,59 @@ public record UpdateProjectRequest(
string? Notes = null, string? Notes = null,
int? TargetWordCount = null); int? TargetWordCount = null);
public class UpdateProjectRequestValidator : IModelValidator<UpdateProjectRequest>
{
public ValidationResult Validate(UpdateProjectRequest model)
{
var result = new ValidationResult();
if (model.Title is not null)
{
if (model.Title.Length == 0)
result.AddError("Title", "'Title' can not be cleared — a project always needs one.");
else if (model.Title.Length > 200)
result.AddError("Title", "'Title' must be 200 characters or fewer.");
}
ProjectValidation.OptionalFields(model.Author, model.Genre, model.Logline, model.Synopsis, model.Notes, model.TargetWordCount, result);
return result;
}
}
file static class ProjectValidation
{
public static void Title(string title, ValidationResult result)
{
if (string.IsNullOrWhiteSpace(title))
result.AddError("Title", "'Title' must not be empty.");
else if (title.Length > 200)
result.AddError("Title", "'Title' must be 200 characters or fewer.");
}
public static void OptionalFields(
string? author, string? genre, string? logline, string? synopsis, string? notes, int? targetWordCount, ValidationResult result)
{
if (author is { Length: > 200 })
result.AddError("Author", "'Author' must be 200 characters or fewer.");
if (genre is { Length: > 100 })
result.AddError("Genre", "'Genre' must be 100 characters or fewer.");
if (logline is { Length: > 500 })
result.AddError("Logline", "'Logline' must be 500 characters or fewer.");
if (synopsis is { Length: > 20000 })
result.AddError("Synopsis", "'Synopsis' must be 20,000 characters or fewer.");
if (notes is { Length: > 20000 })
result.AddError("Notes", "'Notes' must be 20,000 characters or fewer.");
if (targetWordCount is < 0)
result.AddError("TargetWordCount", "'Target Word Count' must be zero or greater.");
}
}
public static class ProjectMapping public static class ProjectMapping
{ {
public static ProjectDto ToDto(this Project p) => new( public static ProjectDto ToDto(this Project p) => new(
+7 -7
View File
@@ -1,4 +1,5 @@
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Projects; namespace Novelly.Api.Projects;
@@ -6,14 +7,16 @@ public static class ProjectEndpoints
{ {
public static IEndpointRouteBuilder MapProjectEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapProjectEndpoints(this IEndpointRouteBuilder app)
{ {
var group = app.MapGroup("/api/projects").WithTags("Projects").AddEndpointFilter<RequestLoggingEndpointFilter>(); var group = app.MapGroup("/api/projects").WithTags("Projects")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
group.MapGet("/", async (ProjectService service, CancellationToken ct) => group.MapGet("/", async (ProjectService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(ct))) Results.Ok(await service.ListAsync(ct)))
.WithSummary("List all novel projects."); .WithSummary("List all novel projects.");
group.MapGet("/{id:guid}", async (Guid id, ProjectService service, CancellationToken ct) => group.MapGet("/{id:guid}", async (Guid id, ProjectService service, CancellationToken ct) =>
Results.Ok(await service.GetAsync(id, ct))) (await service.GetAsync(id, ct)).ToApiResult())
.WithSummary("Read a project's brief."); .WithSummary("Read a project's brief.");
group.MapPost("/", async (CreateProjectRequest request, ProjectService service, CancellationToken ct) => group.MapPost("/", async (CreateProjectRequest request, ProjectService service, CancellationToken ct) =>
@@ -25,14 +28,11 @@ public static class ProjectEndpoints
group.MapPatch("/{id:guid}", async ( group.MapPatch("/{id:guid}", async (
Guid id, UpdateProjectRequest request, ProjectService service, CancellationToken ct) => Guid id, UpdateProjectRequest request, ProjectService service, CancellationToken ct) =>
Results.Ok(await service.UpdateAsync(id, request, ct))) (await service.UpdateAsync(id, request, ct)).ToApiResult())
.WithSummary("Update a project's brief."); .WithSummary("Update a project's brief.");
group.MapDelete("/{id:guid}", async (Guid id, ProjectService service, CancellationToken ct) => group.MapDelete("/{id:guid}", async (Guid id, ProjectService service, CancellationToken ct) =>
{ await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
await service.DeleteAsync(id, ct);
return Results.NoContent();
})
.WithSummary("Delete a project and everything in it."); .WithSummary("Delete a project and everything in it.");
return app; return app;
+39 -9
View File
@@ -1,10 +1,15 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data; using Novelly.Api.Data;
namespace Novelly.Api.Projects; namespace Novelly.Api.Projects;
public class ProjectService(INovelDbContext db, ILogger<ProjectService> logger) public class ProjectService(
INovelDbContext db,
ILogger<ProjectService> logger,
IModelValidator<CreateProjectRequest> createValidator,
IModelValidator<UpdateProjectRequest> updateValidator)
{ {
public async Task<IReadOnlyList<ProjectSummaryDto>> ListAsync(CancellationToken ct = default) public async Task<IReadOnlyList<ProjectSummaryDto>> ListAsync(CancellationToken ct = default)
{ {
@@ -26,14 +31,20 @@ public class ProjectService(INovelDbContext db, ILogger<ProjectService> logger)
.ToListAsync(ct); .ToListAsync(ct);
} }
public async Task<ProjectDto> GetAsync(Guid id, CancellationToken ct = default) /// <summary>Null when no project has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<ProjectDto?> GetAsync(Guid id, CancellationToken ct = default)
{ {
Guard.Default(id, nameof(id));
logger.LogInformation("Getting project {ProjectId}", id); logger.LogInformation("Getting project {ProjectId}", id);
return (await FindAsync(id, ct)).ToDto(); return (await FindAsync(id, ct))?.ToDto();
} }
public async Task<ProjectDto> CreateAsync(CreateProjectRequest request, CancellationToken ct = default) public async Task<ProjectDto> CreateAsync(CreateProjectRequest request, CancellationToken ct = default)
{ {
Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid();
logger.LogInformation("Creating project {Title}", request.Title); logger.LogInformation("Creating project {Title}", request.Title);
var project = new Project var project = new Project
@@ -52,11 +63,19 @@ public class ProjectService(INovelDbContext db, ILogger<ProjectService> logger)
return project.ToDto(); return project.ToDto();
} }
public async Task<ProjectDto> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default) public async Task<ProjectDto?> UpdateAsync(Guid id, UpdateProjectRequest request, CancellationToken ct = default)
{ {
Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request));
updateValidator.Validate(request).ThrowIfInvalid();
logger.LogInformation("Updating project {ProjectId}", id); logger.LogInformation("Updating project {ProjectId}", id);
var project = await FindAsync(id, ct); var project = await FindAsync(id, ct);
if (project is null)
{
return null;
}
project.Title = Patch.Apply(project.Title, request.Title) ?? project.Title; project.Title = Patch.Apply(project.Title, request.Title) ?? project.Title;
project.Author = Patch.Apply(project.Author, request.Author); project.Author = Patch.Apply(project.Author, request.Author);
@@ -71,27 +90,38 @@ public class ProjectService(INovelDbContext db, ILogger<ProjectService> logger)
return project.ToDto(); return project.ToDto();
} }
public async Task DeleteAsync(Guid id, CancellationToken ct = default) /// <summary>True if a project was deleted; false if no project had this id.</summary>
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{ {
Guard.Default(id, nameof(id));
logger.LogInformation("Deleting project {ProjectId}", id); logger.LogInformation("Deleting project {ProjectId}", id);
var project = await FindAsync(id, ct); var project = await FindAsync(id, ct);
if (project is null)
{
return false;
}
db.Projects.Remove(project); db.Projects.Remove(project);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return true;
} }
private async Task<Project> FindAsync(Guid id, CancellationToken ct) private async Task<Project?> FindAsync(Guid id, CancellationToken ct)
{ {
logger.LogDebug("Finding project {ProjectId}", id); logger.LogDebug("Finding project {ProjectId}", id);
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == id, ct); var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == id, ct);
if (project is null) if (project is null)
{ {
logger.LogWarning("Project {ProjectId} not found", id); logger.LogInformation("Project {ProjectId} not found", id);
throw new NotFoundException(nameof(Project), id); }
else
{
logger.LogDebug("Found project {ProjectId}", id);
} }
logger.LogDebug("Found project {ProjectId}", id);
return project; return project;
} }
} }
@@ -1,3 +1,5 @@
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Questions; namespace Novelly.Api.Questions;
public record OpenQuestionDto( public record OpenQuestionDto(
@@ -22,6 +24,24 @@ public record CreateOpenQuestionRequest(
Guid? ChapterId = null, Guid? ChapterId = null,
Guid? CharacterId = null); Guid? CharacterId = null);
public class CreateOpenQuestionRequestValidator : IModelValidator<CreateOpenQuestionRequest>
{
public ValidationResult Validate(CreateOpenQuestionRequest model)
{
var result = new ValidationResult();
if (string.IsNullOrWhiteSpace(model.Question))
result.AddError("Question", "'Question' must not be empty.");
else if (model.Question.Length > 1000)
result.AddError("Question", "'Question' must be 1,000 characters or fewer.");
if (model.Detail is { Length: > 20000 })
result.AddError("Detail", "'Detail' must be 20,000 characters or fewer.");
return result;
}
}
/// <summary> /// <summary>
/// Patch-style update. A null field is left alone; an empty string clears it. Use /// Patch-style update. A null field is left alone; an empty string clears it. Use
/// <see cref="ClearChapter"/> / <see cref="ClearCharacter"/> to detach a question, since a /// <see cref="ClearChapter"/> / <see cref="ClearCharacter"/> to detach a question, since a
@@ -35,6 +55,27 @@ public record UpdateOpenQuestionRequest(
bool ClearChapter = false, bool ClearChapter = false,
bool ClearCharacter = false); bool ClearCharacter = false);
public class UpdateOpenQuestionRequestValidator : IModelValidator<UpdateOpenQuestionRequest>
{
public ValidationResult Validate(UpdateOpenQuestionRequest model)
{
var result = new ValidationResult();
if (model.Question is not null)
{
if (model.Question.Length == 0)
result.AddError("Question", "'Question' can not be cleared — a question always needs one.");
else if (model.Question.Length > 1000)
result.AddError("Question", "'Question' must be 1,000 characters or fewer.");
}
if (model.Detail is { Length: > 20000 })
result.AddError("Detail", "'Detail' must be 20,000 characters or fewer.");
return result;
}
}
/// <summary> /// <summary>
/// Settles a question. The resolution is kept on the question itself; setting /// Settles a question. The resolution is kept on the question itself; setting
/// <see cref="AppendToNotes"/> also appends it to the notes of whatever the question is /// <see cref="AppendToNotes"/> also appends it to the notes of whatever the question is
@@ -42,6 +83,21 @@ public record UpdateOpenQuestionRequest(
/// </summary> /// </summary>
public record ResolveOpenQuestionRequest(string Resolution, bool AppendToNotes = false); public record ResolveOpenQuestionRequest(string Resolution, bool AppendToNotes = false);
public class ResolveOpenQuestionRequestValidator : IModelValidator<ResolveOpenQuestionRequest>
{
public ValidationResult Validate(ResolveOpenQuestionRequest model)
{
var result = new ValidationResult();
if (string.IsNullOrWhiteSpace(model.Resolution))
result.AddError("Resolution", "'Resolution' must not be empty.");
else if (model.Resolution.Length > 20000)
result.AddError("Resolution", "'Resolution' must be 20,000 characters or fewer.");
return result;
}
}
public static class OpenQuestionMapping public static class OpenQuestionMapping
{ {
public static OpenQuestionDto ToDto(this OpenQuestion q) => new( public static OpenQuestionDto ToDto(this OpenQuestion q) => new(
@@ -1,4 +1,5 @@
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Questions; namespace Novelly.Api.Questions;
@@ -6,7 +7,9 @@ public static class OpenQuestionEndpoints
{ {
public static IEndpointRouteBuilder MapOpenQuestionEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapOpenQuestionEndpoints(this IEndpointRouteBuilder app)
{ {
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/questions").WithTags("Questions").AddEndpointFilter<RequestLoggingEndpointFilter>(); var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/questions").WithTags("Questions")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
projectScoped.MapGet("/", async ( projectScoped.MapGet("/", async (
Guid projectId, Guid projectId,
@@ -26,31 +29,30 @@ public static class OpenQuestionEndpoints
}) })
.WithSummary("Raise an open question, optionally against a chapter outline and/or a character."); .WithSummary("Raise an open question, optionally against a chapter outline and/or a character.");
var questions = app.MapGroup("/api/questions").WithTags("Questions").AddEndpointFilter<RequestLoggingEndpointFilter>(); var questions = app.MapGroup("/api/questions").WithTags("Questions")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
questions.MapGet("/{id:guid}", async (Guid id, OpenQuestionService service, CancellationToken ct) => questions.MapGet("/{id:guid}", async (Guid id, OpenQuestionService service, CancellationToken ct) =>
Results.Ok(await service.GetAsync(id, ct))) (await service.GetAsync(id, ct)).ToApiResult())
.WithSummary("Read one question."); .WithSummary("Read one question.");
questions.MapPatch("/{id:guid}", async ( questions.MapPatch("/{id:guid}", async (
Guid id, UpdateOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) => Guid id, UpdateOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) =>
Results.Ok(await service.UpdateAsync(id, request, ct))) (await service.UpdateAsync(id, request, ct)).ToApiResult())
.WithSummary("Update a question or change what it is attached to."); .WithSummary("Update a question or change what it is attached to.");
questions.MapPost("/{id:guid}/resolve", async ( questions.MapPost("/{id:guid}/resolve", async (
Guid id, ResolveOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) => Guid id, ResolveOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) =>
Results.Ok(await service.ResolveAsync(id, request, ct))) (await service.ResolveAsync(id, request, ct)).ToApiResult())
.WithSummary("Settle a question, optionally appending the resolution to the notes it hangs off."); .WithSummary("Settle a question, optionally appending the resolution to the notes it hangs off.");
questions.MapPost("/{id:guid}/reopen", async (Guid id, OpenQuestionService service, CancellationToken ct) => questions.MapPost("/{id:guid}/reopen", async (Guid id, OpenQuestionService service, CancellationToken ct) =>
Results.Ok(await service.ReopenAsync(id, ct))) (await service.ReopenAsync(id, ct)).ToApiResult())
.WithSummary("Put a resolved question back on the list."); .WithSummary("Put a resolved question back on the list.");
questions.MapDelete("/{id:guid}", async (Guid id, OpenQuestionService service, CancellationToken ct) => questions.MapDelete("/{id:guid}", async (Guid id, OpenQuestionService service, CancellationToken ct) =>
{ await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
await service.DeleteAsync(id, ct);
return Results.NoContent();
})
.WithSummary("Delete a question."); .WithSummary("Delete a question.");
return app; return app;
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data; using Novelly.Api.Data;
using Novelly.Api.Projects; using Novelly.Api.Projects;
@@ -11,7 +12,12 @@ namespace Novelly.Api.Questions;
/// The project's open questions — the decisions still outstanding. A question can be /// The project's open questions — the decisions still outstanding. A question can be
/// attached to a chapter outline, a character, both, or neither. /// attached to a chapter outline, a character, both, or neither.
/// </summary> /// </summary>
public class OpenQuestionService(INovelDbContext db, ILogger<OpenQuestionService> logger) public class OpenQuestionService(
INovelDbContext db,
ILogger<OpenQuestionService> logger,
IModelValidator<CreateOpenQuestionRequest> createValidator,
IModelValidator<UpdateOpenQuestionRequest> updateValidator,
IModelValidator<ResolveOpenQuestionRequest> resolveValidator)
{ {
/// <summary> /// <summary>
/// Lists a project's questions, open ones first and newest first within each group. /// Lists a project's questions, open ones first and newest first within each group.
@@ -25,6 +31,8 @@ public class OpenQuestionService(INovelDbContext db, ILogger<OpenQuestionService
bool includeResolved = false, bool includeResolved = false,
CancellationToken ct = default) CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId));
logger.LogInformation( logger.LogInformation(
"Listing open questions for project {ProjectId}, chapter {ChapterId}, character {CharacterId}, includeResolved {IncludeResolved}", "Listing open questions for project {ProjectId}, chapter {ChapterId}, character {CharacterId}, includeResolved {IncludeResolved}",
projectId, chapterId, characterId, includeResolved); projectId, chapterId, characterId, includeResolved);
@@ -57,29 +65,30 @@ public class OpenQuestionService(INovelDbContext db, ILogger<OpenQuestionService
]; ];
} }
public async Task<OpenQuestionDto> GetAsync(Guid id, CancellationToken ct = default) /// <summary>Null when no open question has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<OpenQuestionDto?> GetAsync(Guid id, CancellationToken ct = default)
{ {
Guard.Default(id, nameof(id));
logger.LogInformation("Getting open question {QuestionId}", id); logger.LogInformation("Getting open question {QuestionId}", id);
return (await FindAsync(id, ct)).ToDto(); return (await FindAsync(id, ct))?.ToDto();
} }
public async Task<OpenQuestionDto> CreateAsync( public async Task<OpenQuestionDto> CreateAsync(
Guid projectId, CreateOpenQuestionRequest request, CancellationToken ct = default) Guid projectId, CreateOpenQuestionRequest request, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId));
Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid();
logger.LogInformation("Creating open question for project {ProjectId}", projectId); logger.LogInformation("Creating open question for project {ProjectId}", projectId);
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct)) if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{ {
logger.LogWarning("Project {ProjectId} not found", projectId); logger.LogWarning("Rejected open question creation: project {ProjectId} not found", projectId);
throw new NotFoundException(nameof(Project), projectId); throw new NotFoundException(nameof(Project), projectId);
} }
if (string.IsNullOrWhiteSpace(request.Question))
{
logger.LogWarning("Rejected open question creation for project {ProjectId}: question text was blank", projectId);
throw new ArgumentException("A question needs to say something.");
}
await ValidateAssociationsAsync(projectId, request.ChapterId, request.CharacterId, ct); await ValidateAssociationsAsync(projectId, request.ChapterId, request.CharacterId, ct);
var question = new OpenQuestion var question = new OpenQuestion
@@ -93,15 +102,25 @@ public class OpenQuestionService(INovelDbContext db, ILogger<OpenQuestionService
db.OpenQuestions.Add(question); db.OpenQuestions.Add(question);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return (await FindAsync(question.Id, ct)).ToDto();
// Just created it — the reload is only to pick up includes, not to check existence.
return (await FindAsync(question.Id, ct))!.ToDto();
} }
public async Task<OpenQuestionDto> UpdateAsync( public async Task<OpenQuestionDto?> UpdateAsync(
Guid id, UpdateOpenQuestionRequest request, CancellationToken ct = default) Guid id, UpdateOpenQuestionRequest request, CancellationToken ct = default)
{ {
Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request));
updateValidator.Validate(request).ThrowIfInvalid();
logger.LogInformation("Updating open question {QuestionId}", id); logger.LogInformation("Updating open question {QuestionId}", id);
var question = await FindAsync(id, ct); var question = await FindAsync(id, ct);
if (question is null)
{
return null;
}
await ValidateAssociationsAsync(question.ProjectId, request.ChapterId, request.CharacterId, ct); await ValidateAssociationsAsync(question.ProjectId, request.ChapterId, request.CharacterId, ct);
@@ -112,25 +131,28 @@ public class OpenQuestionService(INovelDbContext db, ILogger<OpenQuestionService
question.UpdatedAt = DateTimeOffset.UtcNow; question.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct)).ToDto(); return (await FindAsync(id, ct))!.ToDto();
} }
/// <summary> /// <summary>
/// Settles a question. With <c>AppendToNotes</c> the resolution is also appended to the /// Settles a question. With <c>AppendToNotes</c> the resolution is also appended to the
/// notes of the chapter and character it hangs off, so the decision ends up where the /// notes of the chapter and character it hangs off, so the decision ends up where the
/// writer reads rather than only in a list they have stopped looking at. /// writer reads rather than only in a list they have stopped looking at. Null when no
/// open question has this id.
/// </summary> /// </summary>
public async Task<OpenQuestionDto> ResolveAsync( public async Task<OpenQuestionDto?> ResolveAsync(
Guid id, ResolveOpenQuestionRequest request, CancellationToken ct = default) Guid id, ResolveOpenQuestionRequest request, CancellationToken ct = default)
{ {
Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request));
resolveValidator.Validate(request).ThrowIfInvalid();
logger.LogInformation("Resolving open question {QuestionId}, appendToNotes {AppendToNotes}", id, request.AppendToNotes); logger.LogInformation("Resolving open question {QuestionId}, appendToNotes {AppendToNotes}", id, request.AppendToNotes);
var question = await FindAsync(id, ct); var question = await FindAsync(id, ct);
if (question is null)
if (string.IsNullOrWhiteSpace(request.Resolution))
{ {
logger.LogWarning("Rejected resolution for open question {QuestionId}: resolution text was blank", id); return null;
throw new ArgumentException("A resolution needs to say what was decided.");
} }
question.Resolution = request.Resolution.Trim(); question.Resolution = request.Resolution.Trim();
@@ -167,31 +189,46 @@ public class OpenQuestionService(INovelDbContext db, ILogger<OpenQuestionService
} }
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct)).ToDto(); return (await FindAsync(id, ct))!.ToDto();
} }
/// <summary>Puts a question back on the list. The resolution goes; anything already appended to notes stays.</summary> /// <summary>Puts a question back on the list. The resolution goes; anything already appended to notes stays. Null when no open question has this id.</summary>
public async Task<OpenQuestionDto> ReopenAsync(Guid id, CancellationToken ct = default) public async Task<OpenQuestionDto?> ReopenAsync(Guid id, CancellationToken ct = default)
{ {
Guard.Default(id, nameof(id));
logger.LogInformation("Reopening open question {QuestionId}", id); logger.LogInformation("Reopening open question {QuestionId}", id);
var question = await FindAsync(id, ct); var question = await FindAsync(id, ct);
if (question is null)
{
return null;
}
question.Resolution = null; question.Resolution = null;
question.ResolvedAt = null; question.ResolvedAt = null;
question.UpdatedAt = DateTimeOffset.UtcNow; question.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct); 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 open question was deleted; false if no question had this id.</summary>
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{ {
Guard.Default(id, nameof(id));
logger.LogInformation("Deleting open question {QuestionId}", id); logger.LogInformation("Deleting open question {QuestionId}", id);
var question = await FindAsync(id, ct); var question = await FindAsync(id, ct);
if (question is null)
{
return false;
}
db.OpenQuestions.Remove(question); db.OpenQuestions.Remove(question);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return true;
} }
/// <summary>Blank line between entries, so appended resolutions stay readable as notes accumulate.</summary> /// <summary>Blank line between entries, so appended resolutions stay readable as notes accumulate.</summary>
@@ -223,18 +260,20 @@ public class OpenQuestionService(INovelDbContext db, ILogger<OpenQuestionService
private IQueryable<OpenQuestion> Query() => private IQueryable<OpenQuestion> Query() =>
db.OpenQuestions.Include(q => q.Chapter).Include(q => q.Character); db.OpenQuestions.Include(q => q.Chapter).Include(q => q.Character);
private async Task<OpenQuestion> FindAsync(Guid id, CancellationToken ct) private async Task<OpenQuestion?> FindAsync(Guid id, CancellationToken ct)
{ {
logger.LogDebug("Finding open question {QuestionId}", id); logger.LogDebug("Finding open question {QuestionId}", id);
var question = await Query().FirstOrDefaultAsync(q => q.Id == id, ct); var question = await Query().FirstOrDefaultAsync(q => q.Id == id, ct);
if (question is null) if (question is null)
{ {
logger.LogWarning("OpenQuestion {QuestionId} not found", id); logger.LogInformation("OpenQuestion {QuestionId} not found", id);
throw new NotFoundException(nameof(OpenQuestion), id); }
else
{
logger.LogDebug("Found open question {QuestionId}", id);
} }
logger.LogDebug("Found open question {QuestionId}", id);
return question; return question;
} }
} }
+66
View File
@@ -1,4 +1,5 @@
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Scenes; namespace Novelly.Api.Scenes;
@@ -31,6 +32,23 @@ public record CreateSceneRequest(
string? Prose = null, string? Prose = null,
DraftStatus Status = DraftStatus.Planned); DraftStatus Status = DraftStatus.Planned);
public class CreateSceneRequestValidator : IModelValidator<CreateSceneRequest>
{
public ValidationResult Validate(CreateSceneRequest model)
{
var result = new ValidationResult();
if (string.IsNullOrWhiteSpace(model.Title))
result.AddError("Title", "'Title' must not be empty.");
else if (model.Title.Length > 200)
result.AddError("Title", "'Title' must be 200 characters or fewer.");
SceneValidation.OptionalFields(model.SortOrder, model.Summary, model.Goal, model.Conflict, model.Outcome, model.Location, model.Prose, result);
return result;
}
}
public record UpdateSceneRequest( public record UpdateSceneRequest(
string? Title = null, string? Title = null,
int? SortOrder = null, int? SortOrder = null,
@@ -43,6 +61,54 @@ public record UpdateSceneRequest(
string? Prose = null, string? Prose = null,
DraftStatus? Status = null); DraftStatus? Status = null);
public class UpdateSceneRequestValidator : IModelValidator<UpdateSceneRequest>
{
public ValidationResult Validate(UpdateSceneRequest model)
{
var result = new ValidationResult();
if (model.Title is not null)
{
if (model.Title.Length == 0)
result.AddError("Title", "'Title' can not be cleared — a scene always needs one.");
else if (model.Title.Length > 200)
result.AddError("Title", "'Title' must be 200 characters or fewer.");
}
SceneValidation.OptionalFields(model.SortOrder, model.Summary, model.Goal, model.Conflict, model.Outcome, model.Location, model.Prose, result);
return result;
}
}
file static class SceneValidation
{
public static void OptionalFields(
int? sortOrder, string? summary, string? goal, string? conflict, string? outcome, string? location, string? prose, ValidationResult result)
{
if (sortOrder is < 0)
result.AddError("SortOrder", "'Sort Order' must be zero or greater.");
if (summary is { Length: > 20000 })
result.AddError("Summary", "'Summary' must be 20,000 characters or fewer.");
if (goal is { Length: > 20000 })
result.AddError("Goal", "'Goal' must be 20,000 characters or fewer.");
if (conflict is { Length: > 20000 })
result.AddError("Conflict", "'Conflict' must be 20,000 characters or fewer.");
if (outcome is { Length: > 20000 })
result.AddError("Outcome", "'Outcome' must be 20,000 characters or fewer.");
if (location is { Length: > 500 })
result.AddError("Location", "'Location' must be 500 characters or fewer.");
if (prose is { Length: > 100000 })
result.AddError("Prose", "'Prose' must be 100,000 characters or fewer.");
}
}
public static class SceneMapping public static class SceneMapping
{ {
public static SceneDto ToDto(this Scene s) => new( public static SceneDto ToDto(this Scene s) => new(
+10 -8
View File
@@ -1,4 +1,5 @@
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Scenes; namespace Novelly.Api.Scenes;
@@ -6,7 +7,9 @@ public static class SceneEndpoints
{ {
public static IEndpointRouteBuilder MapSceneEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapSceneEndpoints(this IEndpointRouteBuilder app)
{ {
var chapterScoped = app.MapGroup("/api/chapters/{chapterId:guid}/scenes").WithTags("Scenes").AddEndpointFilter<RequestLoggingEndpointFilter>(); var chapterScoped = app.MapGroup("/api/chapters/{chapterId:guid}/scenes").WithTags("Scenes")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
chapterScoped.MapGet("/", async (Guid chapterId, SceneService service, CancellationToken ct) => chapterScoped.MapGet("/", async (Guid chapterId, SceneService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(chapterId, ct))) Results.Ok(await service.ListAsync(chapterId, ct)))
@@ -20,22 +23,21 @@ public static class SceneEndpoints
}) })
.WithSummary("Add a scene to a chapter."); .WithSummary("Add a scene to a chapter.");
var scenes = app.MapGroup("/api/scenes").WithTags("Scenes").AddEndpointFilter<RequestLoggingEndpointFilter>(); var scenes = app.MapGroup("/api/scenes").WithTags("Scenes")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
scenes.MapGet("/{id:guid}", async (Guid id, SceneService service, CancellationToken ct) => scenes.MapGet("/{id:guid}", async (Guid id, SceneService service, CancellationToken ct) =>
Results.Ok(await service.GetAsync(id, ct))) (await service.GetAsync(id, ct)).ToApiResult())
.WithSummary("Read a scene, including its prose."); .WithSummary("Read a scene, including its prose.");
scenes.MapPatch("/{id:guid}", async ( scenes.MapPatch("/{id:guid}", async (
Guid id, UpdateSceneRequest request, SceneService service, CancellationToken ct) => Guid id, UpdateSceneRequest request, SceneService service, CancellationToken ct) =>
Results.Ok(await service.UpdateAsync(id, request, ct))) (await service.UpdateAsync(id, request, ct)).ToApiResult())
.WithSummary("Update a scene. Sending prose recomputes the word count."); .WithSummary("Update a scene. Sending prose recomputes the word count.");
scenes.MapDelete("/{id:guid}", async (Guid id, SceneService service, CancellationToken ct) => scenes.MapDelete("/{id:guid}", async (Guid id, SceneService service, CancellationToken ct) =>
{ await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
await service.DeleteAsync(id, ct);
return Results.NoContent();
})
.WithSummary("Delete a scene."); .WithSummary("Delete a scene.");
return app; return app;
+47 -12
View File
@@ -1,14 +1,21 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data; using Novelly.Api.Data;
namespace Novelly.Api.Scenes; namespace Novelly.Api.Scenes;
public class SceneService(INovelDbContext db, ILogger<SceneService> logger) public class SceneService(
INovelDbContext db,
ILogger<SceneService> logger,
IModelValidator<CreateSceneRequest> createValidator,
IModelValidator<UpdateSceneRequest> updateValidator)
{ {
public async Task<IReadOnlyList<SceneDto>> ListAsync(Guid chapterId, CancellationToken ct = default) public async Task<IReadOnlyList<SceneDto>> ListAsync(Guid chapterId, CancellationToken ct = default)
{ {
Guard.Default(chapterId, nameof(chapterId));
logger.LogInformation("Listing scenes for chapter {ChapterId}", chapterId); logger.LogInformation("Listing scenes for chapter {ChapterId}", chapterId);
var scenes = await Query() var scenes = await Query()
@@ -19,19 +26,26 @@ public class SceneService(INovelDbContext db, ILogger<SceneService> logger)
return [.. scenes.Select(s => s.ToDto())]; return [.. scenes.Select(s => s.ToDto())];
} }
public async Task<SceneDto> GetAsync(Guid id, CancellationToken ct = default) /// <summary>Null when no scene has this id — a lookup miss is expected, not exceptional.</summary>
public async Task<SceneDto?> GetAsync(Guid id, CancellationToken ct = default)
{ {
Guard.Default(id, nameof(id));
logger.LogInformation("Getting scene {SceneId}", id); logger.LogInformation("Getting scene {SceneId}", id);
return (await FindAsync(id, ct)).ToDto(); return (await FindAsync(id, ct))?.ToDto();
} }
public async Task<SceneDto> CreateAsync(Guid chapterId, CreateSceneRequest request, CancellationToken ct = default) public async Task<SceneDto> CreateAsync(Guid chapterId, CreateSceneRequest request, CancellationToken ct = default)
{ {
Guard.Default(chapterId, nameof(chapterId));
Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid();
logger.LogInformation("Creating scene {Title} for chapter {ChapterId}, prose length {ProseLength}", request.Title, chapterId, request.Prose?.Length ?? 0); logger.LogInformation("Creating scene {Title} for chapter {ChapterId}, prose length {ProseLength}", request.Title, chapterId, request.Prose?.Length ?? 0);
if (!await db.Chapters.AnyAsync(c => c.Id == chapterId, ct)) if (!await db.Chapters.AnyAsync(c => c.Id == chapterId, ct))
{ {
logger.LogWarning("Chapter {ChapterId} not found", chapterId); logger.LogWarning("Rejected scene creation: chapter {ChapterId} not found", chapterId);
throw new NotFoundException(nameof(Chapter), chapterId); throw new NotFoundException(nameof(Chapter), chapterId);
} }
@@ -53,14 +67,24 @@ public class SceneService(INovelDbContext db, ILogger<SceneService> logger)
db.Scenes.Add(scene); db.Scenes.Add(scene);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return (await FindAsync(scene.Id, ct)).ToDto();
// Just created it — the reload is only to pick up includes, not to check existence.
return (await FindAsync(scene.Id, ct))!.ToDto();
} }
public async Task<SceneDto> UpdateAsync(Guid id, UpdateSceneRequest request, CancellationToken ct = default) public async Task<SceneDto?> UpdateAsync(Guid id, UpdateSceneRequest request, CancellationToken ct = default)
{ {
Guard.Default(id, nameof(id));
Guard.Null(request, nameof(request));
updateValidator.Validate(request).ThrowIfInvalid();
logger.LogInformation("Updating scene {SceneId}, prose length {ProseLength}", id, request.Prose?.Length ?? 0); logger.LogInformation("Updating scene {SceneId}, prose length {ProseLength}", id, request.Prose?.Length ?? 0);
var scene = await FindAsync(id, ct); var scene = await FindAsync(id, ct);
if (scene is null)
{
return null;
}
scene.Title = Patch.Apply(scene.Title, request.Title) ?? scene.Title; scene.Title = Patch.Apply(scene.Title, request.Title) ?? scene.Title;
scene.SortOrder = request.SortOrder ?? scene.SortOrder; scene.SortOrder = request.SortOrder ?? scene.SortOrder;
@@ -81,16 +105,25 @@ public class SceneService(INovelDbContext db, ILogger<SceneService> logger)
scene.UpdatedAt = DateTimeOffset.UtcNow; scene.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct); 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 scene was deleted; false if no scene had this id.</summary>
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{ {
Guard.Default(id, nameof(id));
logger.LogInformation("Deleting scene {SceneId}", id); logger.LogInformation("Deleting scene {SceneId}", id);
var scene = await FindAsync(id, ct); var scene = await FindAsync(id, ct);
if (scene is null)
{
return false;
}
db.Scenes.Remove(scene); db.Scenes.Remove(scene);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return true;
} }
private async Task<int> NextSortOrderAsync(Guid chapterId, CancellationToken ct) private async Task<int> NextSortOrderAsync(Guid chapterId, CancellationToken ct)
@@ -106,18 +139,20 @@ public class SceneService(INovelDbContext db, ILogger<SceneService> logger)
private IQueryable<Scene> Query() => db.Scenes.Include(s => s.PovCharacter); private IQueryable<Scene> Query() => db.Scenes.Include(s => s.PovCharacter);
private async Task<Scene> FindAsync(Guid id, CancellationToken ct) private async Task<Scene?> FindAsync(Guid id, CancellationToken ct)
{ {
logger.LogDebug("Finding scene {SceneId}", id); logger.LogDebug("Finding scene {SceneId}", id);
var scene = await Query().FirstOrDefaultAsync(s => s.Id == id, ct); var scene = await Query().FirstOrDefaultAsync(s => s.Id == id, ct);
if (scene is null) if (scene is null)
{ {
logger.LogWarning("Scene {SceneId} not found", id); logger.LogInformation("Scene {SceneId} not found", id);
throw new NotFoundException(nameof(Scene), id); }
else
{
logger.LogDebug("Found scene {SceneId}", id);
} }
logger.LogDebug("Found scene {SceneId}", id);
return scene; return scene;
} }
} }
+41
View File
@@ -1,3 +1,5 @@
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Tags; namespace Novelly.Api.Tags;
public record TagDto(Guid Id, string Name, string? Color); public record TagDto(Guid Id, string Name, string? Color);
@@ -15,8 +17,47 @@ public record TagSummaryDto(
public record CreateTagRequest(string Name, string? Color = null); public record CreateTagRequest(string Name, string? Color = null);
public class CreateTagRequestValidator : IModelValidator<CreateTagRequest>
{
public ValidationResult Validate(CreateTagRequest model)
{
var result = new ValidationResult();
if (string.IsNullOrWhiteSpace(model.Name))
result.AddError("Name", "'Name' must not be empty.");
else if (model.Name.Length > 100)
result.AddError("Name", "'Name' must be 100 characters or fewer.");
if (model.Color is { Length: > 50 })
result.AddError("Color", "'Color' must be 50 characters or fewer.");
return result;
}
}
public record UpdateTagRequest(string? Name = null, string? Color = null); public record UpdateTagRequest(string? Name = null, string? Color = null);
public class UpdateTagRequestValidator : IModelValidator<UpdateTagRequest>
{
public ValidationResult Validate(UpdateTagRequest model)
{
var result = new ValidationResult();
if (model.Name is not null)
{
if (model.Name.Length == 0)
result.AddError("Name", "'Name' can not be cleared — a tag always needs one.");
else if (model.Name.Length > 100)
result.AddError("Name", "'Name' must be 100 characters or fewer.");
}
if (model.Color is { Length: > 50 })
result.AddError("Color", "'Color' must be 50 characters or fewer.");
return result;
}
}
/// <summary> /// <summary>
/// Everything carrying one tag, gathered in a single response. This is the whole point of /// Everything carrying one tag, gathered in a single response. This is the whole point of
/// tags — seeing that a motif touches two characters, a chapter and four beats is what /// tags — seeing that a motif touches two characters, a chapter and four beats is what
+10 -8
View File
@@ -1,4 +1,5 @@
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Tags; namespace Novelly.Api.Tags;
@@ -6,7 +7,9 @@ public static class TagEndpoints
{ {
public static IEndpointRouteBuilder MapTagEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapTagEndpoints(this IEndpointRouteBuilder app)
{ {
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/tags").WithTags("Tags").AddEndpointFilter<RequestLoggingEndpointFilter>(); var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/tags").WithTags("Tags")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
projectScoped.MapGet("/", async (Guid projectId, TagService service, CancellationToken ct) => projectScoped.MapGet("/", async (Guid projectId, TagService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(projectId, ct))) Results.Ok(await service.ListAsync(projectId, ct)))
@@ -20,22 +23,21 @@ public static class TagEndpoints
}) })
.WithSummary("Create a tag. Tags are also created on demand when applied by name."); .WithSummary("Create a tag. Tags are also created on demand when applied by name.");
var tags = app.MapGroup("/api/tags").WithTags("Tags").AddEndpointFilter<RequestLoggingEndpointFilter>(); var tags = app.MapGroup("/api/tags").WithTags("Tags")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
tags.MapGet("/{id:guid}/references", async (Guid id, TagService service, CancellationToken ct) => tags.MapGet("/{id:guid}/references", async (Guid id, TagService service, CancellationToken ct) =>
Results.Ok(await service.GetReferencesAsync(id, ct))) (await service.GetReferencesAsync(id, ct)).ToApiResult())
.WithSummary("Cross-reference: every character, chapter and beat carrying this tag."); .WithSummary("Cross-reference: every character, chapter and beat carrying this tag.");
tags.MapPatch("/{id:guid}", async ( tags.MapPatch("/{id:guid}", async (
Guid id, UpdateTagRequest request, TagService service, CancellationToken ct) => Guid id, UpdateTagRequest request, TagService service, CancellationToken ct) =>
Results.Ok(await service.UpdateAsync(id, request, ct))) (await service.UpdateAsync(id, request, ct)).ToApiResult())
.WithSummary("Rename or recolour a tag."); .WithSummary("Rename or recolour a tag.");
tags.MapDelete("/{id:guid}", async (Guid id, TagService service, CancellationToken ct) => tags.MapDelete("/{id:guid}", async (Guid id, TagService service, CancellationToken ct) =>
{ await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
await service.DeleteAsync(id, ct);
return Results.NoContent();
})
.WithSummary("Delete a tag. Whatever carried it is left alone."); .WithSummary("Delete a tag. Whatever carried it is left alone.");
return app; return app;
+36 -23
View File
@@ -1,14 +1,21 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data; using Novelly.Api.Data;
using Novelly.Api.Projects; using Novelly.Api.Projects;
namespace Novelly.Api.Tags; namespace Novelly.Api.Tags;
public class TagService(INovelDbContext db, ILogger<TagService> logger) public class TagService(
INovelDbContext db,
ILogger<TagService> logger,
IModelValidator<CreateTagRequest> createValidator,
IModelValidator<UpdateTagRequest> updateValidator)
{ {
public async Task<IReadOnlyList<TagSummaryDto>> ListAsync(Guid projectId, CancellationToken ct = default) public async Task<IReadOnlyList<TagSummaryDto>> ListAsync(Guid projectId, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId));
logger.LogInformation("Listing tags for project {ProjectId}", projectId); logger.LogInformation("Listing tags for project {ProjectId}", projectId);
return await db.Tags return await db.Tags
@@ -20,9 +27,11 @@ public class TagService(INovelDbContext db, ILogger<TagService> logger)
.ToListAsync(ct); .ToListAsync(ct);
} }
/// <summary>Everything in the project carrying this tag.</summary> /// <summary>Everything in the project carrying this tag. Null when no tag has this id.</summary>
public async Task<TagReferencesDto> GetReferencesAsync(Guid tagId, CancellationToken ct = default) public async Task<TagReferencesDto?> GetReferencesAsync(Guid tagId, CancellationToken ct = default)
{ {
Guard.Default(tagId, nameof(tagId));
logger.LogInformation("Getting references for tag {TagId}", tagId); logger.LogInformation("Getting references for tag {TagId}", tagId);
var tag = await db.Tags var tag = await db.Tags
@@ -34,8 +43,8 @@ public class TagService(INovelDbContext db, ILogger<TagService> logger)
if (tag is null) if (tag is null)
{ {
logger.LogWarning("Tag {TagId} not found", tagId); logger.LogInformation("Tag {TagId} not found", tagId);
throw new NotFoundException(nameof(Tag), tagId); return null;
} }
return new TagReferencesDto( return new TagReferencesDto(
@@ -62,20 +71,19 @@ public class TagService(INovelDbContext db, ILogger<TagService> logger)
public async Task<TagDto> CreateAsync(Guid projectId, CreateTagRequest request, CancellationToken ct = default) public async Task<TagDto> CreateAsync(Guid projectId, CreateTagRequest request, CancellationToken ct = default)
{ {
Guard.Default(projectId, nameof(projectId));
Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid();
logger.LogInformation("Creating tag {Name} for project {ProjectId}", request.Name, projectId); logger.LogInformation("Creating tag {Name} for project {ProjectId}", request.Name, projectId);
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct)) if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{ {
logger.LogWarning("Project {ProjectId} not found", projectId); logger.LogWarning("Rejected tag creation: project {ProjectId} not found", projectId);
throw new NotFoundException(nameof(Project), projectId); throw new NotFoundException(nameof(Project), projectId);
} }
var name = TagMapping.Normalise(request.Name); var name = TagMapping.Normalise(request.Name);
if (string.IsNullOrWhiteSpace(name))
{
logger.LogWarning("Rejected tag creation for project {ProjectId}: name was blank", projectId);
throw new ArgumentException("A tag needs a name.");
}
var existing = await FindByNameAsync(projectId, name, ct); var existing = await FindByNameAsync(projectId, name, ct);
if (existing is not null) if (existing is not null)
@@ -90,25 +98,24 @@ public class TagService(INovelDbContext db, ILogger<TagService> logger)
return tag.ToDto(); return tag.ToDto();
} }
public async Task<TagDto> UpdateAsync(Guid tagId, UpdateTagRequest request, CancellationToken ct = default) public async Task<TagDto?> UpdateAsync(Guid tagId, UpdateTagRequest request, CancellationToken ct = default)
{ {
Guard.Default(tagId, nameof(tagId));
Guard.Null(request, nameof(request));
updateValidator.Validate(request).ThrowIfInvalid();
logger.LogInformation("Updating tag {TagId}", tagId); logger.LogInformation("Updating tag {TagId}", tagId);
var tag = await db.Tags.FirstOrDefaultAsync(t => t.Id == tagId, ct); var tag = await db.Tags.FirstOrDefaultAsync(t => t.Id == tagId, ct);
if (tag is null) if (tag is null)
{ {
logger.LogWarning("Tag {TagId} not found", tagId); logger.LogInformation("Tag {TagId} not found", tagId);
throw new NotFoundException(nameof(Tag), tagId); return null;
} }
if (request.Name is not null) if (request.Name is not null)
{ {
var name = TagMapping.Normalise(request.Name); var name = TagMapping.Normalise(request.Name);
if (string.IsNullOrWhiteSpace(name))
{
logger.LogWarning("Rejected update for tag {TagId}: name was blank", tagId);
throw new ArgumentException("A tag needs a name.");
}
var clash = await FindByNameAsync(tag.ProjectId, name, ct); var clash = await FindByNameAsync(tag.ProjectId, name, ct);
if (clash is not null && clash.Id != tag.Id) if (clash is not null && clash.Id != tag.Id)
@@ -125,20 +132,23 @@ public class TagService(INovelDbContext db, ILogger<TagService> logger)
return tag.ToDto(); return tag.ToDto();
} }
/// <summary>Deletes a tag. Whatever carried it keeps existing — only the label goes.</summary> /// <summary>Deletes a tag. Whatever carried it keeps existing — only the label goes. True if deleted.</summary>
public async Task DeleteAsync(Guid tagId, CancellationToken ct = default) public async Task<bool> DeleteAsync(Guid tagId, CancellationToken ct = default)
{ {
Guard.Default(tagId, nameof(tagId));
logger.LogInformation("Deleting tag {TagId}", tagId); logger.LogInformation("Deleting tag {TagId}", tagId);
var tag = await db.Tags.FirstOrDefaultAsync(t => t.Id == tagId, ct); var tag = await db.Tags.FirstOrDefaultAsync(t => t.Id == tagId, ct);
if (tag is null) if (tag is null)
{ {
logger.LogWarning("Tag {TagId} not found", tagId); logger.LogInformation("Tag {TagId} not found", tagId);
throw new NotFoundException(nameof(Tag), tagId); return false;
} }
db.Tags.Remove(tag); db.Tags.Remove(tag);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return true;
} }
/// <summary> /// <summary>
@@ -149,6 +159,9 @@ public class TagService(INovelDbContext db, ILogger<TagService> logger)
internal async Task<List<Tag>> ResolveAsync( internal async Task<List<Tag>> ResolveAsync(
Guid projectId, IReadOnlyList<string> names, CancellationToken ct) Guid projectId, IReadOnlyList<string> names, CancellationToken ct)
{ {
Guard.Default(projectId, nameof(projectId));
Guard.Null(names, nameof(names));
logger.LogDebug("Resolving {Count} tag names for project {ProjectId}", names.Count, projectId); logger.LogDebug("Resolving {Count} tag names for project {ProjectId}", names.Count, projectId);
var wanted = names var wanted = names
+3 -3
View File
@@ -131,7 +131,7 @@ public class BeatServiceTests : ServiceTestFixture
await Scenes.DeleteAsync(scene.Id); await Scenes.DeleteAsync(scene.Id);
// The plan outlives a decision about prose — the beat is simply ungrouped. // The plan outlives a decision about prose — the beat is simply ungrouped.
var survivor = await Beats.GetAsync(beat.Id); var survivor = (await Beats.GetAsync(beat.Id))!;
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -148,7 +148,7 @@ public class BeatServiceTests : ServiceTestFixture
WhatHappened: "Behind the lining of the case.", WhatHappened: "Behind the lining of the case.",
WhatsNext: "She books passage.")); WhatsNext: "She books passage."));
var renamed = await Beats.UpdateAsync(beat.Id, new UpdateBeatRequest(Title: "She finds it")); var renamed = (await Beats.UpdateAsync(beat.Id, new UpdateBeatRequest(Title: "She finds it")))!;
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -156,7 +156,7 @@ public class BeatServiceTests : ServiceTestFixture
Assert.That(renamed.WhatsNext, Is.EqualTo("She books passage.")); Assert.That(renamed.WhatsNext, Is.EqualTo("She books passage."));
}); });
var cleared = await Beats.UpdateAsync(beat.Id, new UpdateBeatRequest(WhatsNext: "")); var cleared = (await Beats.UpdateAsync(beat.Id, new UpdateBeatRequest(WhatsNext: "")))!;
Assert.Multiple(() => Assert.Multiple(() =>
{ {
+7 -9
View File
@@ -28,8 +28,8 @@ public class CharacterArcTests : ServiceTestFixture
Assert.That(mara.Importance, Is.EqualTo(CharacterImportance.Supporting)); Assert.That(mara.Importance, Is.EqualTo(CharacterImportance.Supporting));
var promoted = await Characters.UpdateAsync( var promoted = (await Characters.UpdateAsync(
mara.Id, new UpdateCharacterRequest(Importance: CharacterImportance.Main)); mara.Id, new UpdateCharacterRequest(Importance: CharacterImportance.Main)))!;
Assert.That(promoted.Importance, Is.EqualTo(CharacterImportance.Main)); Assert.That(promoted.Importance, Is.EqualTo(CharacterImportance.Main));
} }
@@ -102,7 +102,7 @@ public class CharacterArcTests : ServiceTestFixture
await Arcs.CreateAsync(_characterId, new CreateArcStageRequest( await Arcs.CreateAsync(_characterId, new CreateArcStageRequest(
"She trusts the map", Description: "Because her mother drew it.")); "She trusts the map", Description: "Because her mother drew it."));
var character = await Characters.GetAsync(_characterId); var character = (await Characters.GetAsync(_characterId))!;
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -163,7 +163,7 @@ public class CharacterArcTests : ServiceTestFixture
await Chapters.DeleteAsync(chapter.Id); await Chapters.DeleteAsync(chapter.Id);
// How a character changes outlives a decision about where the chapter break falls. // How a character changes outlives a decision about where the chapter break falls.
var survivor = await Arcs.GetAsync(stage.Id); var survivor = (await Arcs.GetAsync(stage.Id))!;
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -201,7 +201,7 @@ public class CharacterArcTests : ServiceTestFixture
await Beats.CreateAsync(first.Id, new CreateBeatRequest("Mara lies", CharacterId: mara.Id)); await Beats.CreateAsync(first.Id, new CreateBeatRequest("Mara lies", CharacterId: mara.Id));
await Beats.CreateAsync(first.Id, new CreateBeatRequest("Nobody's beat")); await Beats.CreateAsync(first.Id, new CreateBeatRequest("Nobody's beat"));
var beats = await Beats.ListForCharacterAsync(_characterId); var beats = (await Beats.ListForCharacterAsync(_characterId))!;
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -213,8 +213,6 @@ public class CharacterArcTests : ServiceTestFixture
} }
[Test] [Test]
public void Asking_for_the_beats_of_a_character_who_does_not_exist_reports_not_found() => public async Task Asking_for_the_beats_of_a_character_who_does_not_exist_returns_null_rather_than_throwing() =>
Assert.That( Assert.That(await Beats.ListForCharacterAsync(Guid.NewGuid()), Is.Null);
async () => await Beats.ListForCharacterAsync(Guid.NewGuid()),
Throws.TypeOf<NotFoundException>());
} }
@@ -0,0 +1,57 @@
using Novelly.Api.Chapters;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Projects;
namespace Novelly.Api.Tests;
/// <summary>
/// Covers the exception-handling rework: a missing entity is an ordinary result, not a
/// thrown exception; <see cref="Guard"/> rejects missing required arguments; and a
/// service re-validates a request even when a direct caller skips the API's own filter.
/// </summary>
[TestFixture]
public class ExceptionHandlingTests : ServiceTestFixture
{
[Test]
public void Guard_rejects_an_empty_guid_passed_as_a_required_id() =>
Assert.That(() => Projects.GetAsync(Guid.Empty), Throws.TypeOf<ArgumentException>());
[Test]
public void Guard_rejects_a_null_request_object() =>
Assert.That(
() => Projects.CreateAsync(null!),
Throws.TypeOf<ArgumentNullException>());
[Test]
public async Task Deleting_a_missing_project_returns_false_rather_than_throwing() =>
Assert.That(await Projects.DeleteAsync(Guid.NewGuid()), Is.False);
[Test]
public void A_blank_title_fails_the_create_project_validator()
{
var result = new CreateProjectRequestValidator().Validate(new CreateProjectRequest(""));
Assert.Multiple(() =>
{
Assert.That(result.IsInvalid, Is.True);
Assert.That(result.Errors.Select(e => e.PropertyName), Has.Member("Title"));
});
}
[Test]
public void Calling_a_service_directly_with_an_invalid_request_throws_rather_than_silently_accepting_it() =>
Assert.That(
() => Projects.CreateAsync(new CreateProjectRequest("")),
Throws.TypeOf<ArgumentException>());
[Test]
public async Task An_embedded_reference_to_a_missing_parent_still_throws()
{
// Creating a chapter under a nonexistent project isn't a "look this up" miss — it's
// an invalid precondition for the create, so it stays exceptional.
Assert.That(
async () => await Chapters.CreateAsync(Guid.NewGuid(), new CreateChapterRequest("Landfall")),
Throws.TypeOf<NotFoundException>());
}
}
+2 -1
View File
@@ -99,7 +99,8 @@ public class ListingTests : ServiceTestFixture
new ScriptedModelClient([[new AgentTextBlock("Reply.")]]), new ScriptedModelClient([[new AgentTextBlock("Reply.")]]),
new NovelAgentToolset(Projects, Characters, Arcs, Chapters, Beats, Scenes, Tags, Questions, NullLogger<NovelAgentToolset>.Instance), new NovelAgentToolset(Projects, Characters, Arcs, Chapters, Beats, Scenes, Tags, Questions, NullLogger<NovelAgentToolset>.Instance),
Options.Create(new AgentOptions()), Options.Create(new AgentOptions()),
NullLogger<NovelAgentService>.Instance); NullLogger<NovelAgentService>.Instance,
new SendAgentMessageRequestValidator());
await agent.SendMessageAsync(project.Id, new SendAgentMessageRequest("First question.")); await agent.SendMessageAsync(project.Id, new SendAgentMessageRequest("First question."));
await agent.SendMessageAsync(project.Id, new SendAgentMessageRequest("Second question.")); await agent.SendMessageAsync(project.Id, new SendAgentMessageRequest("Second question."));
+10 -4
View File
@@ -15,14 +15,20 @@ namespace Novelly.Api.Tests;
public class LoggingTests : ServiceTestFixture public class LoggingTests : ServiceTestFixture
{ {
[Test] [Test]
public void Fetching_a_missing_chapter_logs_a_warning_before_throwing() public async Task Fetching_a_missing_chapter_returns_null_and_logs_at_information_not_warning()
{ {
var missingId = Guid.NewGuid(); var missingId = Guid.NewGuid();
Assert.That(() => Chapters.GetAsync(missingId), Throws.TypeOf<NotFoundException>()); var result = await Chapters.GetAsync(missingId);
var warning = ChapterLogs.Entries.Single(e => e.Level == LogLevel.Warning); Assert.Multiple(() =>
Assert.That(warning.Message, Does.Contain(missingId.ToString())); {
Assert.That(result, Is.Null);
Assert.That(ChapterLogs.Entries.Where(e => e.Level == LogLevel.Warning), Is.Empty);
Assert.That(
ChapterLogs.Entries,
Has.Some.Matches<CapturedLogEntry>(e => e.Level == LogLevel.Information && e.Message.Contains(missingId.ToString())));
});
} }
[Test] [Test]
@@ -19,7 +19,8 @@ public class NovelAgentServiceTests : ServiceTestFixture
model, model,
_toolset, _toolset,
Options.Create(new AgentOptions { MaxIterations = 4 }), Options.Create(new AgentOptions { MaxIterations = 4 }),
NullLogger<NovelAgentService>.Instance); NullLogger<NovelAgentService>.Instance,
new SendAgentMessageRequestValidator());
[Test] [Test]
public async Task A_plain_reply_is_persisted_as_a_conversation() public async Task A_plain_reply_is_persisted_as_a_conversation()
@@ -32,7 +33,7 @@ public class NovelAgentServiceTests : ServiceTestFixture
Assert.That(turn.Message.Content, Is.EqualTo("Tell me about the ending.")); Assert.That(turn.Message.Content, Is.EqualTo("Tell me about the ending."));
var conversation = await agent.GetConversationAsync(turn.ConversationId); var conversation = (await agent.GetConversationAsync(turn.ConversationId))!;
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -171,7 +172,7 @@ public class NovelAgentServiceTests : ServiceTestFixture
var second = await agent.SendMessageAsync( var second = await agent.SendMessageAsync(
projectId, new SendAgentMessageRequest("Question two.", first.ConversationId)); projectId, new SendAgentMessageRequest("Question two.", first.ConversationId));
var conversation = await agent.GetConversationAsync(first.ConversationId); var conversation = (await agent.GetConversationAsync(first.ConversationId))!;
Assert.Multiple(() => Assert.Multiple(() =>
{ {
+11 -13
View File
@@ -100,8 +100,8 @@ public class OpenQuestionTests : ServiceTestFixture
var question = await Questions.CreateAsync( var question = await Questions.CreateAsync(
_projectId, new CreateOpenQuestionRequest("Where does the chapter break?")); _projectId, new CreateOpenQuestionRequest("Where does the chapter break?"));
var resolved = await Questions.ResolveAsync( var resolved = (await Questions.ResolveAsync(
question.Id, new ResolveOpenQuestionRequest("After the harbour burns.")); question.Id, new ResolveOpenQuestionRequest("After the harbour burns.")))!;
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -123,8 +123,8 @@ public class OpenQuestionTests : ServiceTestFixture
question.Id, question.Id,
new ResolveOpenQuestionRequest("After the harbour burns.", AppendToNotes: true)); new ResolveOpenQuestionRequest("After the harbour burns.", AppendToNotes: true));
var chapter = await Chapters.GetAsync(_chapterId); var chapter = (await Chapters.GetAsync(_chapterId))!;
var character = await Characters.GetAsync(_characterId); var character = (await Characters.GetAsync(_characterId))!;
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -143,7 +143,7 @@ public class OpenQuestionTests : ServiceTestFixture
await Questions.ResolveAsync(question.Id, new ResolveOpenQuestionRequest("After the harbour.")); await Questions.ResolveAsync(question.Id, new ResolveOpenQuestionRequest("After the harbour."));
Assert.That((await Chapters.GetAsync(_chapterId)).Notes, Is.Null); Assert.That((await Chapters.GetAsync(_chapterId))!.Notes, Is.Null);
} }
[Test] [Test]
@@ -155,8 +155,8 @@ public class OpenQuestionTests : ServiceTestFixture
await Questions.ResolveAsync( await Questions.ResolveAsync(
question.Id, new ResolveOpenQuestionRequest("After the harbour.", AppendToNotes: true)); question.Id, new ResolveOpenQuestionRequest("After the harbour.", AppendToNotes: true));
var reopened = await Questions.ReopenAsync(question.Id); var reopened = (await Questions.ReopenAsync(question.Id))!;
var chapter = await Chapters.GetAsync(_chapterId); var chapter = (await Chapters.GetAsync(_chapterId))!;
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -172,8 +172,8 @@ public class OpenQuestionTests : ServiceTestFixture
var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest( var question = await Questions.CreateAsync(_projectId, new CreateOpenQuestionRequest(
"Where does the chapter break?", ChapterId: _chapterId, CharacterId: _characterId)); "Where does the chapter break?", ChapterId: _chapterId, CharacterId: _characterId));
var detached = await Questions.UpdateAsync( var detached = (await Questions.UpdateAsync(
question.Id, new UpdateOpenQuestionRequest(ClearChapter: true)); question.Id, new UpdateOpenQuestionRequest(ClearChapter: true)))!;
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -192,7 +192,7 @@ public class OpenQuestionTests : ServiceTestFixture
await Chapters.DeleteAsync(_chapterId); await Chapters.DeleteAsync(_chapterId);
var survivor = await Questions.GetAsync(question.Id); var survivor = (await Questions.GetAsync(question.Id))!;
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -212,9 +212,7 @@ public class OpenQuestionTests : ServiceTestFixture
Assert.Multiple(async () => Assert.Multiple(async () =>
{ {
Assert.That(await Questions.ListAsync(_projectId, includeResolved: true), Is.Empty); Assert.That(await Questions.ListAsync(_projectId, includeResolved: true), Is.Empty);
Assert.That( Assert.That(await Questions.GetAsync(question.Id), Is.Null);
async () => await Questions.GetAsync(question.Id),
Throws.TypeOf<NotFoundException>());
}); });
} }
+10 -12
View File
@@ -19,7 +19,7 @@ public class ProjectDataTests : ServiceTestFixture
var id = (await Projects.CreateAsync( var id = (await Projects.CreateAsync(
new CreateProjectRequest("Draft", Genre: "Fantasy", Logline: "A cartographer goes to sea."))).Id; new CreateProjectRequest("Draft", Genre: "Fantasy", Logline: "A cartographer goes to sea."))).Id;
var afterPartialUpdate = await Projects.UpdateAsync(id, new UpdateProjectRequest(Title: "The Salt Road")); var afterPartialUpdate = (await Projects.UpdateAsync(id, new UpdateProjectRequest(Title: "The Salt Road")))!;
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -28,7 +28,7 @@ public class ProjectDataTests : ServiceTestFixture
Assert.That(afterPartialUpdate.Logline, Is.EqualTo("A cartographer goes to sea.")); Assert.That(afterPartialUpdate.Logline, Is.EqualTo("A cartographer goes to sea."));
}); });
var afterClear = await Projects.UpdateAsync(id, new UpdateProjectRequest(Genre: "")); var afterClear = (await Projects.UpdateAsync(id, new UpdateProjectRequest(Genre: "")))!;
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -63,12 +63,12 @@ public class ProjectDataTests : ServiceTestFixture
Assert.That(scene.WordCount, Is.EqualTo(5)); Assert.That(scene.WordCount, Is.EqualTo(5));
var rewritten = await Scenes.UpdateAsync(scene.Id, new UpdateSceneRequest( var rewritten = (await Scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(
Prose: "Now\nthere are seven words in total")); Prose: "Now\nthere are seven words in total")))!;
Assert.That(rewritten.WordCount, Is.EqualTo(7)); Assert.That(rewritten.WordCount, Is.EqualTo(7));
var cleared = await Scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(Prose: "")); var cleared = (await Scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(Prose: "")))!;
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -85,7 +85,7 @@ public class ProjectDataTests : ServiceTestFixture
var scene = await Scenes.CreateAsync(chapter.Id, new CreateSceneRequest( var scene = await Scenes.CreateAsync(chapter.Id, new CreateSceneRequest(
"The dock at dawn", Prose: "The tide came in slow.")); "The dock at dawn", Prose: "The tide came in slow."));
var updated = await Scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(Status: DraftStatus.Revised)); var updated = (await Scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(Status: DraftStatus.Revised)))!;
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -138,8 +138,8 @@ public class ProjectDataTests : ServiceTestFixture
var ines = await Characters.CreateAsync(projectId, new CreateCharacterRequest("Ines")); var ines = await Characters.CreateAsync(projectId, new CreateCharacterRequest("Ines"));
var mara = await Characters.CreateAsync(projectId, new CreateCharacterRequest("Mara")); var mara = await Characters.CreateAsync(projectId, new CreateCharacterRequest("Mara"));
var updated = await Characters.AddRelationshipAsync( var updated = (await Characters.AddRelationshipAsync(
ines.Id, new CreateRelationshipRequest(mara.Id, "sister", "Estranged since the fire.")); ines.Id, new CreateRelationshipRequest(mara.Id, "sister", "Estranged since the fire.")))!;
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -149,8 +149,6 @@ public class ProjectDataTests : ServiceTestFixture
} }
[Test] [Test]
public void Reading_a_missing_project_reports_not_found() => public async Task Reading_a_missing_project_returns_null_rather_than_throwing() =>
Assert.That( Assert.That(await Projects.GetAsync(Guid.NewGuid()), Is.Null);
async () => await Projects.GetAsync(Guid.NewGuid()),
Throws.TypeOf<NotFoundException>());
} }
+16 -8
View File
@@ -52,14 +52,22 @@ public abstract class ServiceTestFixture
ArcLogs = new CapturingLogger<CharacterArcService>(); ArcLogs = new CapturingLogger<CharacterArcService>();
QuestionLogs = new CapturingLogger<OpenQuestionService>(); QuestionLogs = new CapturingLogger<OpenQuestionService>();
Tags = new TagService(Db.Context, TagLogs); Tags = new TagService(Db.Context, TagLogs, new CreateTagRequestValidator(), new UpdateTagRequestValidator());
Projects = new ProjectService(Db.Context, ProjectLogs); Projects = new ProjectService(Db.Context, ProjectLogs, new CreateProjectRequestValidator(), new UpdateProjectRequestValidator());
Characters = new CharacterService(Db.Context, Tags, CharacterLogs); Characters = new CharacterService(
Chapters = new ChapterService(Db.Context, Tags, ChapterLogs); Db.Context, Tags, CharacterLogs,
Scenes = new SceneService(Db.Context, SceneLogs); new CreateCharacterRequestValidator(), new UpdateCharacterRequestValidator(), new CreateRelationshipRequestValidator());
Beats = new BeatService(Db.Context, Tags, BeatLogs); Chapters = new ChapterService(Db.Context, Tags, ChapterLogs, new CreateChapterRequestValidator(), new UpdateChapterRequestValidator());
Arcs = new CharacterArcService(Db.Context, ArcLogs); Scenes = new SceneService(Db.Context, SceneLogs, new CreateSceneRequestValidator(), new UpdateSceneRequestValidator());
Questions = new OpenQuestionService(Db.Context, QuestionLogs); Beats = new BeatService(
Db.Context, Tags, BeatLogs,
new CreateBeatRequestValidator(), new UpdateBeatRequestValidator(), new ReorderBeatsRequestValidator());
Arcs = new CharacterArcService(
Db.Context, ArcLogs,
new CreateArcStageRequestValidator(), new UpdateArcStageRequestValidator(), new ReorderArcStagesRequestValidator());
Questions = new OpenQuestionService(
Db.Context, QuestionLogs,
new CreateOpenQuestionRequestValidator(), new UpdateOpenQuestionRequestValidator(), new ResolveOpenQuestionRequestValidator());
OnSetUp(); OnSetUp();
} }
+6 -6
View File
@@ -54,8 +54,8 @@ public class TagServiceTests : ServiceTestFixture
var character = await Characters.CreateAsync( var character = await Characters.CreateAsync(
_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal", "the sea"])); _projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal", "the sea"]));
var updated = await Characters.UpdateAsync( var updated = (await Characters.UpdateAsync(
character.Id, new UpdateCharacterRequest(Tags: ["the sea", "maps"])); character.Id, new UpdateCharacterRequest(Tags: ["the sea", "maps"])))!;
Assert.That(updated.Tags.Select(t => t.Name), Is.EquivalentTo(new[] { "the sea", "maps" })); Assert.That(updated.Tags.Select(t => t.Name), Is.EquivalentTo(new[] { "the sea", "maps" }));
} }
@@ -66,8 +66,8 @@ public class TagServiceTests : ServiceTestFixture
var character = await Characters.CreateAsync( var character = await Characters.CreateAsync(
_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"])); _projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"]));
var updated = await Characters.UpdateAsync( var updated = (await Characters.UpdateAsync(
character.Id, new UpdateCharacterRequest(Occupation: "Cartographer")); character.Id, new UpdateCharacterRequest(Occupation: "Cartographer")))!;
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -88,7 +88,7 @@ public class TagServiceTests : ServiceTestFixture
await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Unrelated beat")); await Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Unrelated beat"));
var tagId = (await Tags.ListAsync(_projectId)).Single().Id; var tagId = (await Tags.ListAsync(_projectId)).Single().Id;
var references = await Tags.GetReferencesAsync(tagId); var references = (await Tags.GetReferencesAsync(tagId))!;
Assert.Multiple(() => Assert.Multiple(() =>
{ {
@@ -165,7 +165,7 @@ public class TagServiceTests : ServiceTestFixture
await Tags.DeleteAsync(tagId); await Tags.DeleteAsync(tagId);
var survivor = await Characters.GetAsync(character.Id); var survivor = (await Characters.GetAsync(character.Id))!;
Assert.Multiple(() => Assert.Multiple(() =>
{ {